Skip to main content

microvms_app/control/
microvm.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Launch, the launch wait, the lifecycle calls, and the proxy token (TRAP-5, TRAP-8).
3//!
4//! # TRAP-5: the payload ceiling is 4096 bytes, inclusive
5//!
6//! [`RunHookPayload`] cannot hold an over-ceiling value, so the check happens when the
7//! payload is *made* rather than when the request is sent — which means every path into
8//! `RunMicrovm` is closed by one guard instead of each needing its own.
9//!
10//! The number is worth stating precisely because the project got it wrong in the dangerous
11//! direction. `docs/STRATEGY.md` and `docs/TRUST.md` claimed 16 KB — and so does the
12//! service model's own **documentation** string for `runHookPayload`, which reads "Maximum:
13//! 16,384 bytes" while its shape `RunMicrovmRequestRunHookPayloadString` says `max: 4096`.
14//! So a reader who checks the docs rather than the shape reproduces the bug. 4x wrong in
15//! the direction that tells a caller four times as much secret material fits as actually
16//! does. Bracketed 2026-08-07 by calling `RunMicrovm` with a deliberately bogus
17//! `imageIdentifier` so nothing could be created or billed: **4096 passes, 4097 fails.**
18//!
19//! Measured in UTF-8 **bytes**, not characters. A payload counted by character length
20//! passes while the same value with one multi-byte character in it does not.
21//!
22//! # TRAP-5's other half: why the payload at all
23//!
24//! It is the only per-VM secret channel the platform offers, and it is what keeps the agent
25//! token out of the shared image snapshot. That is safe because the platform forwards no
26//! external traffic until the run hook returns 200, so a per-VM secret delivered at launch
27//! wins the first-writer race through the endpoint.
28//!
29//! The daemon reads it one JSON parse deeper than expected: the platform **wraps** the
30//! string, so the hook body is `{"runHookPayload": "{\"agent_token\": \"...\"}"}`.
31//!
32//! # TRAP-8: a terminal state before RUNNING
33//!
34//! A VM that reaches a terminal state before RUNNING died during startup, and for a
35//! hook-serving daemon that almost always means a lifecycle hook failed. Polling through it
36//! wastes minutes and then reports a connection error that hides the cause — and by then
37//! the VM is gone, so `stateReason` is the only evidence left. [`ControlPlane::wait_for_running`]
38//! fails fast and attaches both the state and the reason.
39//!
40//! # TRAP-11, again: the shell mint is a separate surface, not a sibling of exec's
41//!
42//! [`ControlPlane::mint_auth_token`] calls `CreateMicrovmAuthToken` and is what the exec
43//! path uses. [`ControlPlane::mint_shell_auth_token`] calls `CreateMicrovmShellAuthToken`
44//! and exists for exactly one caller: `microvm shell`, the interactive PTY (issue #69).
45//! Nothing on the exec or session path reaches it — the lifecycle test below drives
46//! everything exec can do and asserts zero shell calls were emitted. See [`super`].
47
48use super::transport::{Call, paths};
49use super::{ControlPlane, RunMicrovmRequest, WaitOpts, ops, timed_out, token};
50use crate::error::{Error, ErrorKind};
51
52/// The proxy token's auth header, which is also the key in the `authToken` map.
53pub const PROXY_AUTH_HEADER: &str = "X-aws-proxy-auth";
54
55/// The proxy port header.
56///
57/// Sent on **every** endpoint request alongside the auth header: `X-aws-proxy-auth` without
58/// `X-aws-proxy-port` is rejected in a way that reads like a bad token, which is a long
59/// detour for a missing header.
60pub const PROXY_PORT_HEADER: &str = "X-aws-proxy-port";
61
62/// The service ceiling on a proxy token's life, in minutes.
63pub const MAX_TOKEN_MINUTES: u32 = 60;
64
65/// A `runHookPayload` that is known to fit the service ceiling.
66///
67/// The type is the guard: there is no way to build one over 4096 bytes, so no call site has
68/// to remember to check. See the module docs for the measurement and for why the service
69/// model's own documentation string disagrees with its shape.
70#[derive(Clone, Eq, PartialEq)]
71pub struct RunHookPayload(String);
72
73/// Prints the size and nothing else.
74///
75/// The payload is the platform's only per-VM secret channel — its whole purpose is to carry
76/// the agent token — so a derived `Debug` would print that token into every log line that
77/// formats one, and into every line that formats the [`RunMicrovmRequest`] holding it.
78/// [`RunMicrovmRequest`] keeps its derive: with this impl in place, the derived one prints
79/// `RunHookPayload(<N bytes>)` for that field, so the safety is inherited rather than
80/// restated. The length is kept because it is the number every TRAP-5 diagnosis needs.
81impl std::fmt::Debug for RunHookPayload {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        write!(f, "RunHookPayload(<{} bytes>)", self.0.len())
84    }
85}
86
87impl RunHookPayload {
88    /// The payload carrying an agent token, as the daemon expects to find it.
89    ///
90    /// `{"agent_token": "<token>"}` — and the platform wraps *this whole string* as the
91    /// value of `runHookPayload`, which is the extra parse the daemon does.
92    ///
93    /// Checked even though this function builds the JSON itself, because the token is
94    /// caller-supplied: someone passing a JWT or a signed blob rather than a bearer token is
95    /// exactly who this catches.
96    pub fn for_agent_token(agent_token: &str) -> Result<Self, Error> {
97        Self::for_launch(agent_token, &std::collections::HashMap::new())
98    }
99
100    /// The payload carrying an agent token and a launch environment.
101    ///
102    /// `{"agent_token": "<token>", "env": {...}}`, and the platform wraps that whole
103    /// string as the value of `runHookPayload` — the extra parse the daemon does.
104    /// The daemon applies `env` as the base environment of every subsequent exec,
105    /// under the per-request `env`.
106    ///
107    /// **This is where the 4096-byte refusal fires for a launch env.** The check is
108    /// on the serialized payload rather than on the map, because the ceiling is on
109    /// the string: JSON escaping, the key names, and the punctuation all count, so a
110    /// caller who measured their own values would measure the wrong thing. And it is
111    /// local rather than left to the service for the same reason
112    /// [`super::super::control::image`]'s `require_workdir` and
113    /// `require_matching_from` are local: the service's answer arrives as a
114    /// `ValidationException` on a member the caller did not know they were filling,
115    /// after the call, and botocore does not enforce it client-side at all (measured;
116    /// `docs/PLATFORM.md`). `env` is what makes this reachable in practice — one
117    /// bearer token has always fit with room to spare, and a map of credentials does
118    /// not.
119    ///
120    /// The env is omitted from the JSON entirely when it is empty, so a caller who
121    /// passes no launch env produces byte-for-byte the payload
122    /// [`RunHookPayload::for_agent_token`] always produced. A pinned daemon that has
123    /// never heard of `env` therefore sees no change, and the two constructors do not
124    /// have two different budgets.
125    pub fn for_launch(
126        agent_token: &str,
127        env: &std::collections::HashMap<String, String>,
128    ) -> Result<Self, Error> {
129        Self::for_launch_with_identity(agent_token, env, None)
130    }
131
132    /// [`RunHookPayload::for_launch`], optionally carrying the tunnel identity material.
133    ///
134    /// The two identity fields are the VM's own seed and the launching host's public key,
135    /// under the keys `protocol::identity` declares — the daemon pins one and derives from
136    /// the other, which lets `microvm tunnel --verify-identity` prove possession of the
137    /// launch-pinned key under `protocol::identity`'s trust assumptions. `None` produces byte-for-byte the
138    /// payload the other constructors always built, so a caller who never asks for identity
139    /// cannot be affected by the field existing (the same compatibility rule `env` follows).
140    ///
141    /// The material costs [`protocol::identity::IDENTITY_PAYLOAD_BYTES`] of the shared
142    /// 4096-byte budget — two 44-character base64 values plus JSON framing — and the ceiling
143    /// check below covers it like everything else in the payload.
144    pub fn for_launch_with_identity(
145        agent_token: &str,
146        env: &std::collections::HashMap<String, String>,
147        identity: Option<&crate::identity::LaunchIdentity>,
148    ) -> Result<Self, Error> {
149        let mut payload = if env.is_empty() {
150            serde_json::json!({ "agent_token": agent_token })
151        } else {
152            serde_json::json!({ "agent_token": agent_token, "env": env })
153        };
154        if let Some(identity) = identity {
155            let object = payload
156                .as_object_mut()
157                .expect("the payload is built as an object two lines up");
158            object.insert(
159                protocol::identity::SEED_KEY.to_string(),
160                serde_json::Value::String(identity.seed_field()),
161            );
162            object.insert(
163                protocol::identity::HOST_PUBLIC_KEY_KEY.to_string(),
164                serde_json::Value::String(identity.host_public_field()),
165            );
166        }
167        let text = serde_json::to_string(&payload).map_err(|error| {
168            Error::new(
169                ErrorKind::Unexpected,
170                format!("could not serialize the run-hook payload: {error}"),
171            )
172        })?;
173        // The length is read before the value moves into `new`, so the refusal below can
174        // account for it without rebuilding the payload.
175        let total = text.len();
176        Self::new(text).map_err(|error| {
177            if env.is_empty() {
178                return error;
179            }
180            // The generic message names the byte count and the ceiling, which is the
181            // whole diagnosis for a token. For a launch env it is half of one: the
182            // caller wants to know how much of the budget their env is, because the
183            // fix is to move a value out of it rather than to shorten the token.
184            Error::invalid_arg(format!(
185                "{error} The launch env contributed {} of those bytes across {} variable(s); \
186                 the payload's other {} are the token and the JSON framing. A launch env is \
187                 for small values a workload needs at startup — move credential-scale \
188                 material to PUT /v1/fs/file after bootstrap, or to a role the workload \
189                 assumes.",
190                env_contribution(agent_token, env),
191                env.len(),
192                total.saturating_sub(env_contribution(agent_token, env)),
193            ))
194        })
195    }
196
197    /// An arbitrary payload, if it fits.
198    ///
199    /// Public because the run-hook body is the caller's channel and this crate does not own
200    /// what goes in it; the ceiling is the only thing it enforces.
201    pub fn new(payload: impl Into<String>) -> Result<Self, Error> {
202        let payload = payload.into();
203        // Bytes, not characters: the ceiling is on the serialized string, so a payload
204        // measured by character count passes while the same value with one multi-byte
205        // character in it does not.
206        let size = payload.len();
207        if size <= crate::constants::MAX_RUN_HOOK_PAYLOAD_BYTES {
208            return Ok(Self(payload));
209        }
210        Err(Error::invalid_arg(format!(
211            "runHookPayload is {size} bytes, over the ceiling of {} (service model {}, measured \
212             inclusive 2026-08-07: 4096 passes and 4097 fails). This is the only per-VM secret \
213             channel the platform offers — one bearer token fits, a cloud credential set does \
214             not. Note that docs/STRATEGY.md, docs/TRUST.md, and the service model's own \
215             documentation string for this member all claim 16 KB, which is wrong by 4x in the \
216             dangerous direction; the shape RunMicrovmRequestRunHookPayloadString is the \
217             authority and it states max 4096.",
218            crate::constants::MAX_RUN_HOOK_PAYLOAD_BYTES,
219            crate::constants::MODEL_API_VERSION,
220        )))
221    }
222
223    /// The payload's bytes as the wire will carry them.
224    pub fn as_str(&self) -> &str {
225        &self.0
226    }
227
228    /// The size in bytes, which is what the ceiling measures.
229    pub fn len(&self) -> usize {
230        self.0.len()
231    }
232
233    /// Whether the payload is empty. The model's minimum is 0, so an empty payload is legal.
234    pub fn is_empty(&self) -> bool {
235        self.0.is_empty()
236    }
237}
238
239/// How many of a launch payload's bytes the env accounts for.
240///
241/// Measured by difference against the token-only payload rather than by summing key and
242/// value lengths, because the ceiling is on the *serialized* string: the `"env":{}`
243/// wrapper, the quotes, the commas, and every backslash JSON escaping adds all count
244/// against the budget, and a figure that summed the raw values would understate exactly
245/// the payloads that are near the limit.
246///
247/// Only ever called on the refusal path, where the payload has already been built once.
248fn env_contribution(agent_token: &str, env: &std::collections::HashMap<String, String>) -> usize {
249    let with = serde_json::json!({ "agent_token": agent_token, "env": env }).to_string();
250    let without = serde_json::json!({ "agent_token": agent_token }).to_string();
251    with.len().saturating_sub(without.len())
252}
253
254/// A launched MicroVM, as the service last described it.
255#[derive(Clone, Debug, Eq, PartialEq)]
256pub struct Microvm {
257    pub id: String,
258    pub state: String,
259    /// The proxy endpoint. Present from the launch response onward.
260    pub endpoint: String,
261    pub image_arn: String,
262    pub image_version: String,
263    /// Why the VM is in this state, when the service said. The absence is information —
264    /// TRAP-8's message distinguishes "no stateReason" from an empty one.
265    pub state_reason: Option<String>,
266    /// The idle policy the service reports the VM is running under.
267    ///
268    /// Carried rather than dropped because it is the platform's own account of the window
269    /// that will suspend and then terminate this VM, and it was measured coming back on a
270    /// real `GetMicrovm` — see [`ops::IdlePolicy`], which used to claim it does not. The
271    /// client's request is what *asked* for a window; this is what the service says it got,
272    /// and a caller diagnosing a VM that vanished earlier than expected needs the second.
273    ///
274    /// `Option` because the model does not mark the member required on either response
275    /// shape, so an absent one must parse rather than fail.
276    pub idle_policy: Option<ops::IdlePolicy>,
277    /// `maximumDurationInSeconds` as the service reports it.
278    pub maximum_duration_seconds: Option<u32>,
279    /// When the VM first started (`startedAt`). Absent on a launch reply.
280    pub started_at: Option<std::time::SystemTime>,
281    /// When the VM terminated (`terminatedAt`), once it has.
282    pub terminated_at: Option<std::time::SystemTime>,
283}
284
285/// Epoch seconds from a `Timestamp` member, or `None` for an absent or unrepresentable one.
286///
287/// The checked conversion refuses a negative, NaN, infinite or too-large figure. The unchecked
288/// one, behind a filter for the first three, panicked on a figure past what a `Duration` holds
289/// before `checked_add` could refuse it (#335).
290fn epoch(seconds: Option<f64>) -> Option<std::time::SystemTime> {
291    let since = std::time::Duration::try_from_secs_f64(seconds?).ok()?;
292    std::time::UNIX_EPOCH.checked_add(since)
293}
294
295/// The optional filters `ListMicrovms` accepts.
296#[derive(Clone, Debug, Default, Eq, PartialEq)]
297pub struct MicrovmFilter {
298    /// Only VMs running this image (ARN or ID).
299    pub image_identifier: Option<String>,
300    /// Only VMs running this image version.
301    pub image_version: Option<String>,
302}
303
304impl Microvm {
305    /// The reason the service gave, or a phrase saying it gave none.
306    ///
307    /// A phrase rather than an empty string, because "the service said nothing" and "the
308    /// service said nothing useful" are different diagnoses and a caller reading a message
309    /// has to be able to tell them apart.
310    pub(crate) fn reason(&self) -> &str {
311        self.state_reason.as_deref().unwrap_or("no stateReason")
312    }
313}
314
315impl From<ops::MicrovmResponseWire> for Microvm {
316    fn from(wire: ops::MicrovmResponseWire) -> Self {
317        Self {
318            id: wire.microvm_id,
319            state: wire.state,
320            endpoint: wire.endpoint,
321            image_arn: wire.image_arn,
322            image_version: wire.image_version,
323            state_reason: wire.state_reason,
324            idle_policy: wire.idle_policy,
325            maximum_duration_seconds: wire.maximum_duration_in_seconds,
326            started_at: epoch(wire.started_at),
327            terminated_at: epoch(wire.terminated_at),
328        }
329    }
330}
331
332/// The minted proxy token, as a header map (TRAP-7).
333///
334/// # Why a map rather than a string
335///
336/// Because that is what the service answers: `authToken` is a `TokenParts` map, and the
337/// value at `X-aws-proxy-auth` is the header value. A client that treated the response as a
338/// string would send a stringified map and get a rejection that reads like a bad token.
339///
340/// [`ProxyToken::headers`] always emits **both** headers, which is the other half of the
341/// same trap: `X-aws-proxy-auth` without `X-aws-proxy-port` is rejected the same
342/// indistinguishable way.
343#[derive(Clone, Eq, PartialEq)]
344pub struct ProxyToken {
345    value: String,
346    port: u16,
347}
348
349/// Names the headers and the port, never the token value.
350///
351/// A minted proxy token is a credential, and a derived `Debug` would print it into every
352/// log line that formats a [`ProxyToken`], a [`RunMicrovmRequest`] holding one, or an error
353/// chain containing either. The sibling type in [`crate::session::proxy`] made this choice
354/// first; this is the same rule on the control-plane side of the conversion.
355impl std::fmt::Debug for ProxyToken {
356    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
357        f.debug_struct("ProxyToken")
358            .field("headers", &[PROXY_AUTH_HEADER, PROXY_PORT_HEADER])
359            .field("port", &self.port)
360            .finish()
361    }
362}
363
364impl ProxyToken {
365    /// Reads the token out of the service's `authToken` map.
366    ///
367    /// A missing `X-aws-proxy-auth` key is [`ErrorKind::Retryable`] via
368    /// [`crate::error::WireKind::AuthTokenMint`]: minting is inside the request retry path
369    /// (TRAP-9), and a throttle at minute 30 of a long run must not kill a trial that is
370    /// otherwise healthy.
371    pub fn from_map(
372        map: &std::collections::BTreeMap<String, String>,
373        port: u16,
374    ) -> Result<Self, Error> {
375        Self::from_operation_map("CreateMicrovmAuthToken", map, port)
376    }
377
378    /// [`ProxyToken::from_map`], naming the operation whose response the map came from.
379    ///
380    /// The shell mint reads the same `TokenParts` shape out of
381    /// `CreateMicrovmShellAuthToken`, and a missing-key failure that blamed the ordinary
382    /// mint would send the reader to the wrong operation's response.
383    pub fn from_operation_map(
384        operation: &str,
385        map: &std::collections::BTreeMap<String, String>,
386        port: u16,
387    ) -> Result<Self, Error> {
388        let value = map.get(PROXY_AUTH_HEADER).ok_or_else(|| {
389            Error::wire(
390                crate::error::WireKind::AuthTokenMint,
391                format!(
392                    "the {operation} response carried no {PROXY_AUTH_HEADER} key. The \
393                     authToken member is a map, not a string, and that key is the header value \
394                     (keys present: {:?}).",
395                    map.keys().collect::<Vec<_>>()
396                ),
397            )
398        })?;
399        Ok(Self {
400            value: value.clone(),
401            port,
402        })
403    }
404
405    /// Both headers every endpoint request needs.
406    ///
407    /// Both, always. See the type docs: sending only the auth header is rejected in a way
408    /// that reads like a bad token.
409    pub fn headers(&self) -> [(&'static str, String); 2] {
410        [
411            (PROXY_AUTH_HEADER, self.value.clone()),
412            (PROXY_PORT_HEADER, self.port.to_string()),
413        ]
414    }
415
416    /// The raw token value.
417    pub fn value(&self) -> &str {
418        &self.value
419    }
420
421    /// The port the token was minted for.
422    pub fn port(&self) -> u16 {
423        self.port
424    }
425}
426
427impl ControlPlane {
428    /// Launches a MicroVM.
429    ///
430    /// TRAP-5 is already closed by [`RunHookPayload`]'s type; the duration range is checked
431    /// here, and the connectors are derived from intents (TRAP-4). The `clientToken` is
432    /// minted from a label (TRAP-1).
433    pub async fn run_microvm(&self, request: RunMicrovmRequest) -> Result<Microvm, Error> {
434        self.run_microvm_reported(request, None)
435            .await
436            .map(|(vm, _)| vm)
437    }
438
439    pub(crate) async fn run_microvm_reported(
440        &self,
441        request: RunMicrovmRequest,
442        cancellation: Option<&super::transport::RetryCancellation>,
443    ) -> Result<(Microvm, super::transport::RetryDetails), Error> {
444        let (ingress, egress) = self.launch_connectors(&request)?;
445
446        let wire = ops::RunMicrovmWire {
447            image_identifier: request.image_identifier.clone(),
448            image_version: request.image_version.clone(),
449            execution_role_arn: request.execution_role_arn.clone(),
450            ingress_network_connectors: ingress,
451            // Absent rather than empty: omitting egress omits the connector from the request.
452            // Measured 2026-09-11 and 2026-09-12 the platform still gave such a VM outbound
453            // network (`docs/PLATFORM.md`, "A VM launched without the egress connector still
454            // has outbound network").
455            egress_network_connectors: (!egress.is_empty()).then_some(egress),
456            idle_policy: ops::IdlePolicy {
457                max_idle_duration_seconds: request.max_idle_sec,
458                suspended_duration_seconds: request.suspended_sec,
459                auto_resume_enabled: request.auto_resume,
460            },
461            maximum_duration_in_seconds: request.max_duration_sec,
462            run_hook_payload: request.run_hook_payload.as_str().to_string(),
463            logging: request.logging.clone(),
464            client_token: match request.client_token {
465                Some(token) => token,
466                None => token::run_token_with(
467                    request
468                        .token_scope
469                        .as_deref()
470                        .unwrap_or(&request.image_identifier),
471                    self.entropy(),
472                )?,
473            },
474        };
475
476        let call = Call::post_json("RunMicrovm", paths::microvms(), &wire)?;
477        let (reply, details) = super::transport::send_cancellable(
478            self.transport(),
479            call,
480            &[],
481            self.retry,
482            self.clock.as_ref(),
483            cancellation,
484        )
485        .await?;
486        let reply = super::ReportedReply { reply, details };
487        let launched: ops::MicrovmResponseWire = reply.json("RunMicrovm")?;
488        Ok((launched.into(), details))
489    }
490
491    /// Every local refusal [`Self::run_microvm`] makes, then the ingress and egress connector
492    /// ARNs the launch carries. Local; zero calls.
493    ///
494    /// Separate so [`crate::sandbox::Sandbox::run`] can ask it before it resolves an image
495    /// name: that listing is a call, and a launch this client refuses anyway should cost none.
496    pub(crate) fn launch_connectors(
497        &self,
498        request: &RunMicrovmRequest,
499    ) -> Result<(Vec<String>, Vec<String>), Error> {
500        if let Some(token) = request.client_token.as_deref() {
501            super::token::require_run_token(token)?;
502        }
503        super::require_valid_identifier("imageIdentifier", &request.image_identifier)?;
504        super::require_duration_in_range(request.max_duration_sec)?;
505        // `min: 60` on `IdlePolicyMaxIdleDurationSecondsInteger`. There used to be no guard for
506        // this on the grounds that botocore enforces `min` locally — true of the deleted Python
507        // client and never of this one. See `super::require_idle_duration`.
508        super::require_idle_duration(request.max_idle_sec)?;
509        // Checked before the wire because a pinned launch is what a rollback does, and a
510        // `ValidationException` on the member rather than on the version is the least useful
511        // failure available at that moment. See `super::require_valid_version`.
512        if let Some(version) = request.image_version.as_deref() {
513            super::require_valid_version("imageVersion", version)?;
514        }
515        // `RoleArn` — optional in the model, and every real launch has one. A malformed value
516        // here is a `ValidationException` on a member the caller may not know is being filled
517        // from their infra config, so the refusal names it.
518        if let Some(role) = request.execution_role_arn.as_deref() {
519            super::require_valid_role_arn("executionRoleArn", role)?;
520        }
521        if let Some(ops::Logging::CloudWatch { cloud_watch }) = &request.logging {
522            let Some(group) = cloud_watch.log_group.as_deref() else {
523                return Err(Error::invalid_arg(
524                    "per-VM CloudWatch logging needs a logGroup; pass a group name or disable logging",
525                ));
526            };
527            super::require_valid_log_group(group)?;
528            if let Some(stream) = cloud_watch.log_stream.as_deref() {
529                super::require_valid_log_stream(stream)?;
530            }
531        }
532
533        // `ALL_INGRESS` cannot be combined with any other ingress connector, and the
534        // platform says so only at token-mint time: `RunMicrovm` accepts the invalid
535        // set, the VM reaches RUNNING, and it bills until something asks for a shell
536        // token (`docs/PLATFORM.md`, measured 2026-08-15). Refused here instead, before
537        // anything launches or bills. The pair that works is `[HTTP_INGRESS,
538        // SHELL_INGRESS]`.
539        let has_all_ingress = request
540            .connectors
541            .contains(&super::ConnectorIntent::AllIngress);
542        let has_finer_ingress = request.connectors.iter().any(|intent| {
543            matches!(
544                intent,
545                super::ConnectorIntent::HttpIngress | super::ConnectorIntent::ShellIngress
546            )
547        });
548        if has_all_ingress && has_finer_ingress {
549            return Err(Error::invalid_arg(
550                "ALL_INGRESS cannot be combined with other ingress connectors; use \
551                 HTTP_INGRESS and/or SHELL_INGRESS instead. The platform accepts the \
552                 combination at launch and rejects it only when a shell token is minted, \
553                 after the VM has launched and billed — so it is refused here.",
554            ));
555        }
556
557        // The other half of the same measured constraint: the pair that launches and
558        // mints a shell token is `[HTTP_INGRESS, SHELL_INGRESS]`. A shell-only ingress
559        // set has no measured success path, and the platform accepts an invalid set at
560        // launch — so it too is refused here, before anything launches or bills.
561        let has_shell_ingress = request
562            .connectors
563            .contains(&super::ConnectorIntent::ShellIngress);
564        let has_http_ingress = request
565            .connectors
566            .contains(&super::ConnectorIntent::HttpIngress);
567        if has_shell_ingress && !has_http_ingress {
568            return Err(Error::invalid_arg(
569                "SHELL_INGRESS was requested without HTTP_INGRESS. The measured pair that \
570                 launches and mints a shell token is [HTTP_INGRESS, SHELL_INGRESS] \
571                 (docs/PLATFORM.md); the platform accepts an invalid ingress set at launch \
572                 and the VM runs and bills before any failure appears — so it is refused \
573                 here.",
574            ));
575        }
576
577        // Ingress and egress go in separate members, so they are split by intent rather
578        // than concatenated into one list.
579        let ingress: Vec<String> = request
580            .connectors
581            .iter()
582            .filter(|intent| {
583                matches!(
584                    intent,
585                    super::ConnectorIntent::AllIngress
586                        | super::ConnectorIntent::HttpIngress
587                        | super::ConnectorIntent::ShellIngress
588                )
589            })
590            .map(|intent| intent.arn(&self.region))
591            .collect();
592        let mut egress: Vec<String> = request
593            .connectors
594            .iter()
595            .filter(|intent| matches!(intent, super::ConnectorIntent::Egress))
596            .map(|intent| intent.arn(&self.region))
597            .collect();
598
599        // The same refusals, in the same order and words, as `egress_posture_for`, which
600        // `Sandbox::run` asks first; these stand for a caller of `run_microvm` directly.
601        if !egress.is_empty() && !request.egress_network_connectors.is_empty() {
602            return Err(super::connector::egress_with_connectors());
603        }
604        for arn in &request.egress_network_connectors {
605            super::connector::require_egress_connector_arn(arn, &self.region)?;
606            egress.push(arn.clone());
607        }
608
609        // Each request member is a NetworkConnectorList with its own limit.
610        for (member, count) in [
611            ("ingressNetworkConnectors", ingress.len()),
612            ("egressNetworkConnectors", egress.len()),
613        ] {
614            if let Some(refusal) = super::connector::over_connector_ceiling(member, count) {
615                return Err(refusal);
616            }
617        }
618
619        Ok((ingress, egress))
620    }
621
622    /// Polls to RUNNING, failing fast on a terminal state (TRAP-8).
623    ///
624    /// The fast failure is the whole value. A VM that reaches a terminal state before
625    /// RUNNING died during startup; polling through it wastes minutes and then reports a
626    /// connection error that hides the cause, and by then the VM is gone so `stateReason` is
627    /// the only evidence left.
628    pub async fn wait_for_running(&self, id: &str, opts: WaitOpts) -> Result<Microvm, Error> {
629        self.wait_for_state(id, &["RUNNING"], &crate::constants::TERMINAL_STATES, opts)
630            .await
631    }
632
633    /// Polls a launch that may have **adopted** an existing VM through its client token.
634    ///
635    /// A repeated `clientToken` makes `RunMicrovm` answer with the VM the first attempt
636    /// launched, and that VM may have idle-suspended since (#195). [`Self::wait_for_running`]
637    /// would read that SUSPENDED as a death during startup (TRAP-8) and report LaunchDied
638    /// for a healthy VM. Here SUSPENDING is waited through, SUSPENDED is resumed once, and
639    /// only the dead states fail fast. A genuine startup death still ends in a dead state,
640    /// so it still fails with `stateReason`.
641    pub async fn wait_for_launch(&self, id: &str, opts: WaitOpts) -> Result<Microvm, Error> {
642        self.wait_for_launch_inner(id, opts, true, false).await
643    }
644
645    /// Sandbox launches poll quickly while a typical launch is becoming ready. Public
646    /// control-plane waits keep the caller's fixed polling interval.
647    pub(crate) async fn wait_for_sandbox_launch(
648        &self,
649        id: &str,
650        opts: WaitOpts,
651        adoptable: bool,
652    ) -> Result<Microvm, Error> {
653        self.wait_for_launch_inner(id, opts, adoptable, true).await
654    }
655
656    async fn wait_for_launch_inner(
657        &self,
658        id: &str,
659        opts: WaitOpts,
660        adoptable: bool,
661        fast_start: bool,
662    ) -> Result<Microvm, Error> {
663        let started = self.clock().elapsed();
664        let mut resumed = false;
665        let mut fast_polls_remaining = 3;
666        let fail_on = if adoptable {
667            &crate::constants::DEAD_STATES[..]
668        } else {
669            &crate::constants::TERMINAL_STATES[..]
670        };
671        loop {
672            let got = self.get_microvm(id).await?;
673            match got.state.as_str() {
674                "RUNNING" => return Ok(got),
675                state if fail_on.contains(&state) => {
676                    return Err(self.reached_terminal_state(&got, &["RUNNING"]));
677                }
678                // `GetMicrovm` is eventually consistent, so SUSPENDED can still be read
679                // just after the resume was accepted; one resume is enough.
680                "SUSPENDED" if adoptable && !resumed => {
681                    self.resume(id).await?;
682                    resumed = true;
683                    continue;
684                }
685                _ => {}
686            }
687
688            let elapsed = self.clock().elapsed().saturating_sub(started);
689            if elapsed >= opts.timeout {
690                return Err(timed_out(
691                    &format!(
692                        "microvm {id} never reached [\"RUNNING\"] (last state {})",
693                        got.state
694                    ),
695                    elapsed,
696                ));
697            }
698            // Poll promptly during startup, then use the lifecycle interval to bound
699            // repeated requests during a slower launch.
700            let interval = if fast_start && fast_polls_remaining > 0 {
701                fast_polls_remaining -= 1;
702                opts.poll_interval.min(std::time::Duration::from_secs(1))
703            } else {
704                opts.poll_interval
705            };
706            self.clock().sleep(interval).await;
707        }
708    }
709
710    /// Polls until the VM reaches one of `wanted`, failing fast on any of `fail_on`.
711    ///
712    /// # Why `fail_on` is a parameter rather than always the terminal set
713    ///
714    /// Because `suspend` **wants** SUSPENDED and tolerates TERMINATED: a VM that dies while
715    /// suspending is a state to report, not an exception to raise out of the middle of a
716    /// teardown. The resume path passes the *dead* states instead, because a VM the idle
717    /// policy terminated during suspension never reaches RUNNING — and waiting only for
718    /// RUNNING there burns the full timeout and then reports "never reached RUNNING", a
719    /// timeout message hiding a cause the service had already stated.
720    pub async fn wait_for_state(
721        &self,
722        id: &str,
723        wanted: &[&str],
724        fail_on: &[&str],
725        opts: WaitOpts,
726    ) -> Result<Microvm, Error> {
727        let started = self.clock().elapsed();
728        loop {
729            let got = self.get_microvm(id).await?;
730
731            if wanted.contains(&got.state.as_str()) {
732                return Ok(got);
733            }
734            if fail_on.contains(&got.state.as_str()) {
735                return Err(self.reached_terminal_state(&got, wanted));
736            }
737
738            let elapsed = self.clock().elapsed().saturating_sub(started);
739            if elapsed >= opts.timeout {
740                return Err(timed_out(
741                    &format!(
742                        "microvm {id} never reached {wanted:?} (last state {})",
743                        got.state
744                    ),
745                    elapsed,
746                ));
747            }
748            self.clock().sleep(opts.poll_interval).await;
749        }
750    }
751
752    /// TRAP-8's error: the state **and** the reason, both in the message.
753    ///
754    /// Both because either alone is unactionable. The state says the VM is gone; the reason
755    /// says why, and it is the only evidence that survives the VM.
756    fn reached_terminal_state(&self, got: &Microvm, wanted: &[&str]) -> Error {
757        Error::new(
758            ErrorKind::LaunchDied,
759            format!(
760                "microvm {} reached {} before {wanted:?}: {}. A VM that reaches a terminal state \
761                 before RUNNING died during startup, and for a hook-serving daemon that almost \
762                 always means a lifecycle hook failed — the stateReason above is the only evidence \
763                 left, because the VM is gone (docs/PLATFORM.md, 'MicroVM states, and terminal \
764                 states reached before RUNNING').",
765                got.id,
766                got.state,
767                got.reason(),
768            ),
769        )
770    }
771
772    /// `GetMicrovm`.
773    ///
774    /// `microvmIdentifier` is a URI parameter, so an empty one collapses the path onto the
775    /// listing and asks a different question — see [`super::require_valid_identifier`]. This is
776    /// the read every wait loop makes, so the check also covers
777    /// [`Self::wait_for_state`] and everything built on it.
778    pub async fn get_microvm(&self, id: &str) -> Result<Microvm, Error> {
779        super::require_valid_identifier("microvmIdentifier", id)?;
780        let call = Call::get("GetMicrovm", paths::microvm(id));
781        let reply = self.send(call).await?;
782        let got: ops::MicrovmResponseWire = reply.json("GetMicrovm")?;
783        Ok(got.into())
784    }
785
786    /// `ListMicrovms`, read to its last page.
787    ///
788    /// # Why every page
789    ///
790    /// `maxResults` caps at 50, so a fleet above 50 VMs read from one page is a
791    /// **confidently wrong** answer rather than a missing one: the caller gets a list that
792    /// looks complete, and a VM absent from it is a VM nothing here will terminate. That is
793    /// the same argument [`ops::ListImagesResponseWire`] makes for the image listing, and a
794    /// fleet listing is the one a teardown reads.
795    ///
796    /// Each VM is listed once (#280): a VM the service returns on two pages keeps the place it
797    /// was first listed and the entry read last, the newer reading of its state.
798    pub async fn list_microvms(&self) -> Result<Vec<ops::MicrovmItemWire>, Error> {
799        self.list_microvms_matching(&MicrovmFilter::default()).await
800    }
801
802    /// [`Self::list_microvms`], narrowed by the service's image and version filters.
803    pub async fn list_microvms_matching(
804        &self,
805        filter: &MicrovmFilter,
806    ) -> Result<Vec<ops::MicrovmItemWire>, Error> {
807        Ok(self
808            .list_microvm_summaries(filter)
809            .await?
810            .into_iter()
811            .map(Into::into)
812            .collect())
813    }
814
815    /// The paginated listing with optional `started_at` metadata. This adds metadata without
816    /// changing the exhaustively constructible item returned by the original listing methods.
817    pub async fn list_microvm_summaries(
818        &self,
819        filter: &MicrovmFilter,
820    ) -> Result<Vec<ops::MicrovmSummaryWire>, Error> {
821        if let Some(image) = filter.image_identifier.as_deref() {
822            super::require_valid_identifier("imageIdentifier", image)?;
823        }
824        if let Some(version) = filter.image_version.as_deref() {
825            super::require_valid_version("imageVersion", version)?;
826        }
827        let mut items: Vec<ops::MicrovmSummaryWire> = Vec::new();
828        // Where each id sits in `items`. The service can return one VM on two pages while VMs
829        // change state (docs/PLATFORM.md, "A paginated `ListMicrovms` can return one VM on two
830        // of its pages"), so a repeat replaces the earlier entry, the older reading, in place.
831        let mut position: std::collections::HashMap<String, usize> =
832            std::collections::HashMap::new();
833        let mut next_token: Option<String> = None;
834        loop {
835            let call = Call::get(
836                "ListMicrovms",
837                paths::microvms_list_matching(
838                    filter.image_identifier.as_deref(),
839                    filter.image_version.as_deref(),
840                    next_token.as_deref(),
841                ),
842            );
843            let reply = self.send(call).await?;
844            let page: ops::ListMicrovmSummariesResponseWire = reply.json("ListMicrovms")?;
845            for item in page.items {
846                match position.get(&item.microvm_id) {
847                    Some(&at) => items[at] = item,
848                    None => {
849                        position.insert(item.microvm_id.clone(), items.len());
850                        items.push(item);
851                    }
852                }
853            }
854            match page.next_token {
855                Some(token) => next_token = Some(token),
856                None => return Ok(items),
857            }
858        }
859    }
860
861    /// `SuspendMicrovm`. Freezes the VM; the caller waits for SUSPENDED.
862    pub async fn suspend(&self, id: &str) -> Result<(), Error> {
863        super::require_valid_identifier("microvmIdentifier", id)?;
864        let call = Call::post_empty("SuspendMicrovm", paths::suspend(id));
865        self.send(call).await?;
866        Ok(())
867    }
868
869    /// `ResumeMicrovm`. Thaws the VM; no token re-delivery and no re-bootstrap.
870    ///
871    /// # A VM that ended before the resume is `WindowClosed` (STATE-12)
872    ///
873    /// Nothing is checked against the suspended window before the call, because the service
874    /// decides whether a suspended VM can still resume. It terminates a VM the idle policy
875    /// suspended once `suspendedDurationSeconds` passes, and it doesn't hold an explicit
876    /// `SuspendMicrovm` to that window: measured 2026-10-12 in us-east-1, an explicitly
877    /// suspended VM resumed 1283 s into a 60 s window (#597). A resume of a VM that has
878    /// terminated is refused with a `ValidationException` ("The MicroVM <id> has been
879    /// terminated and its state cannot be changed."), the same status as any refused value,
880    /// and it was the same refusal for a VM the service ended and one `TerminateMicrovm`
881    /// ended (measured 2026-10-12, us-east-1). So a refusal reads the VM's state rather than
882    /// the message, and a VM that's TERMINATING or TERMINATED is [`ErrorKind::WindowClosed`]
883    /// with its state and `stateReason`, carrying the refusal as its source. The error names
884    /// no cause, because the client doesn't know which of the suspended window, the maximum
885    /// duration, or a terminate ended the VM; the `stateReason` is the service's word on it.
886    /// Every other refusal comes back as the service gave it.
887    pub async fn resume(&self, id: &str) -> Result<(), Error> {
888        super::require_valid_identifier("microvmIdentifier", id)?;
889        let call = Call::post_empty("ResumeMicrovm", paths::resume(id));
890        match self.send(call).await {
891            Ok(_) => Ok(()),
892            Err(refused) if refused.kind() == ErrorKind::Platform => {
893                Err(self.ended_before_resume(id, refused).await)
894            }
895            Err(error) => Err(error),
896        }
897    }
898
899    /// The error a refused resume reports: [`ErrorKind::WindowClosed`] with `refused` as its
900    /// source when `GetMicrovm` reports the VM dead, and `refused` otherwise, including when
901    /// that read fails.
902    async fn ended_before_resume(&self, id: &str, refused: Error) -> Error {
903        let Ok(vm) = self.get_microvm(id).await else {
904            return refused;
905        };
906        if !crate::constants::DEAD_STATES.contains(&vm.state.as_str()) {
907            return refused;
908        }
909        Error::new(
910            ErrorKind::WindowClosed,
911            format!(
912                "microvm {id} can't be resumed: the service refused ResumeMicrovm, and \
913                 GetMicrovm reports the VM {} ({}). A VM that has ended doesn't come back; \
914                 launch a new one.",
915                vm.state,
916                vm.reason(),
917            ),
918        )
919        .with_source(refused)
920    }
921
922    /// `TerminateMicrovm`.
923    ///
924    /// The identifier check matters most on this one, and for a reason the others do not share:
925    /// the path is `DELETE /microvms/{microvmIdentifier}`, so an empty identifier is a `DELETE`
926    /// addressed at the **collection**. There is no operation on that path and the service
927    /// refuses it, but "send a delete at the fleet and rely on the service to refuse" is not a
928    /// thing to leave to the service.
929    pub async fn terminate(&self, id: &str) -> Result<(), Error> {
930        super::require_valid_identifier("microvmIdentifier", id)?;
931        let call = Call::delete("TerminateMicrovm", paths::microvm(id));
932        self.send(call).await?;
933        Ok(())
934    }
935
936    /// Mints a proxy token for the agent port (TRAP-7, TRAP-9).
937    ///
938    /// `expirationInMinutes` is capped at [`MAX_TOKEN_MINUTES`] here rather than passed
939    /// through, because over-asking is rejected and the rejection reads like a bad request
940    /// rather than like a ceiling. The *refresh* interval — half the ceiling — belongs to
941    /// the endpoint client that holds the token (T-W2-5), not here.
942    ///
943    /// Scoped to the agent port alone, which is right for every request this client makes on
944    /// its own behalf and **wrong for a caller reaching another port on the same VM**. That
945    /// caller wants [`Self::mint_auth_token_for`]; see [`ops::PortSpecification`] for the
946    /// measurement that made the distinction necessary.
947    pub async fn mint_auth_token(&self, id: &str) -> Result<ProxyToken, Error> {
948        self.mint_auth_token_for(id, &[ops::PortSpecification::port(self.port())])
949            .await
950    }
951
952    /// Mints a proxy token scoped to `ports`, whatever they are.
953    ///
954    /// The agent port is **not** added for you. A caller naming ports is answering the
955    /// question the token asks, and silently widening the scope would hand back a
956    /// credential broader than the one requested — the opposite of the mistake this exists
957    /// to fix, and a worse one, since it is invisible. A caller that needs both the agent
958    /// port and a workload port names both, which is what [`ProxyAuth`] does.
959    ///
960    /// The returned token still records the agent port as its *default* header port, because
961    /// that is the port a request carries when nobody names one.
962    ///
963    /// [`ProxyAuth`]: crate::session::ProxyAuth
964    pub async fn mint_auth_token_for(
965        &self,
966        id: &str,
967        ports: &[ops::PortSpecification],
968    ) -> Result<ProxyToken, Error> {
969        super::require_valid_identifier("microvmIdentifier", id)?;
970        if ports.is_empty() {
971            // The model declares `min: 1` on `allowedPorts`, so an empty list is a
972            // round-trip that comes back as a validation error naming a member the caller
973            // did not know it was sending. Refused here, with the reason.
974            return Err(Error::new(
975                ErrorKind::Precondition,
976                "a proxy token must allow at least one port ('allowedPorts' declares min: 1). \
977                 Name the ports this token is for — the agent port plus any workload port \
978                 reached through the endpoint.",
979            ));
980        }
981        // `PortNumber` is `min: 1`, and the value that reaches here as a 0 is not a typo — it is
982        // a caller who took `ControlPlane::with_port(0)`'s default port, or who passed a
983        // `SocketAddr`'s port before binding. A token minted for port 0 authorizes nothing, and
984        // the proxy's refusal is `403 Access to port denied`, which reads like the token being
985        // wrong rather than its scope being empty (measured 2026-08-15, see
986        // `ops::PortSpecification`). Both ends of a range are checked, because `PortRange`'s two
987        // members name the same shape and a range starting at 0 is the same defect.
988        for spec in ports {
989            match spec {
990                ops::PortSpecification::One { port } => {
991                    super::require_valid_port("allowedPorts[].port", *port)?;
992                }
993                ops::PortSpecification::Range { range } => {
994                    super::require_valid_port("allowedPorts[].range.startPort", range.start_port)?;
995                    super::require_valid_port("allowedPorts[].range.endPort", range.end_port)?;
996                }
997                // `allPorts` names no number, so there is nothing to bound.
998                ops::PortSpecification::All { .. } => {}
999            }
1000        }
1001        let wire = ops::CreateAuthTokenWire {
1002            expiration_in_minutes: MAX_TOKEN_MINUTES,
1003            allowed_ports: ports.to_vec(),
1004        };
1005        let call = Call::post_json("CreateMicrovmAuthToken", paths::auth_token(id), &wire)?;
1006        let reply = self.send(call).await?;
1007        let minted: ops::CreateAuthTokenResponseWire = reply.json("CreateMicrovmAuthToken")?;
1008        ProxyToken::from_map(&minted.auth_token, self.port()).map_err(|error| reply.error(error))
1009    }
1010
1011    /// Mints a shell token: the credential `microvm shell` opens the PTY WebSocket with.
1012    ///
1013    /// `CreateMicrovmShellAuthToken`, which the VM must have been launched with
1014    /// `SHELL_INGRESS` to answer — a VM launched without it gets a `ValidationException`
1015    /// naming the connector, and `RunMicrovmRequest::with_shell` is how a launch requests
1016    /// the measured pair. The request takes only the expiration; there is no
1017    /// `allowedPorts`, because the shell is not a port (`docs/PLATFORM.md`, 2026-08-15).
1018    ///
1019    /// # This is `microvm shell`'s method, not exec's (TRAP-11)
1020    ///
1021    /// One interactive session is not programmatic exec — no exec ids, no idempotency,
1022    /// no separated stdout/stderr, no exit codes — so nothing on the exec or session
1023    /// path calls this, and the lifecycle test asserts that absence. A caller holding
1024    /// the returned token has a real root PTY on the VM; treat it like the credential
1025    /// it is.
1026    ///
1027    /// The expiration is pinned at [`MAX_TOKEN_MINUTES`] for the reason
1028    /// [`Self::mint_auth_token`] gives: over-asking is rejected in a way that reads like
1029    /// a bad request rather than like a ceiling. A shell session held past the ceiling
1030    /// keeps its already-open WebSocket — the expiry gates the *handshake*, not an
1031    /// established session.
1032    pub async fn mint_shell_auth_token(&self, id: &str) -> Result<ProxyToken, Error> {
1033        super::require_valid_identifier("microvmIdentifier", id)?;
1034        let wire = ops::CreateShellAuthTokenWire {
1035            expiration_in_minutes: MAX_TOKEN_MINUTES,
1036        };
1037        let call = Call::post_json(
1038            "CreateMicrovmShellAuthToken",
1039            paths::shell_auth_token(id),
1040            &wire,
1041        )?;
1042        let reply = self.send(call).await?;
1043        let minted: ops::CreateShellAuthTokenResponseWire =
1044            reply.json("CreateMicrovmShellAuthToken")?;
1045        ProxyToken::from_operation_map(
1046            "CreateMicrovmShellAuthToken",
1047            &minted.auth_token,
1048            self.port(),
1049        )
1050        .map_err(|error| reply.error(error))
1051    }
1052}
1053
1054/// The states a suspend may end in.
1055///
1056/// TERMINATED is *wanted* rather than a failure: a VM that dies while suspending is a state
1057/// to report, not an exception to raise out of the middle of a teardown.
1058pub const SUSPEND_WANTED: [&str; 2] = ["SUSPENDED", "TERMINATED"];
1059
1060#[cfg(test)]
1061mod tests {
1062    use std::sync::Arc;
1063
1064    use super::super::fake::{self as fake, Answer, FakeControlPlane, TestClock};
1065    use super::*;
1066    use crate::region::Region;
1067
1068    /// A suspend waits for these, so each has to be a state the service can report. The rest
1069    /// of the polling loops' sets are checked beside the enum in `constants`; this one lives
1070    /// here, so its check does too.
1071    #[test]
1072    fn every_state_a_suspend_waits_for_is_in_the_models_enum() {
1073        for state in SUSPEND_WANTED {
1074            assert!(
1075                crate::constants::MICROVM_STATES.contains(&state),
1076                "SUSPEND_WANTED has {state}, which is not a MicrovmState: a suspend would wait \
1077                 for a state the service can't report and time out"
1078            );
1079        }
1080    }
1081
1082    fn planted() -> (ControlPlane, Arc<FakeControlPlane>, Arc<TestClock>) {
1083        let fake = Arc::new(FakeControlPlane::new());
1084        let clock = Arc::new(TestClock::new());
1085        let plane = crate::testing::control_plane(fake.clone(), Region::UsEast1, clock.clone());
1086        (plane, fake, clock)
1087    }
1088
1089    #[tokio::test]
1090    async fn post_response_failures_keep_request_retry_details() {
1091        let (plane, fake, _) = planted();
1092        fake.answer("GetMicrovm", Answer::throttled_as_400())
1093            .answer("GetMicrovm", Answer::ok("{}"));
1094        let malformed = plane.get_microvm("mvm-one").await.unwrap_err();
1095        assert_eq!(malformed.kind(), ErrorKind::Platform);
1096        assert_eq!(malformed.attempt_count(), 2);
1097        assert!(!malformed.backoff().is_zero());
1098        assert!(!malformed.throttled());
1099
1100        let (plane, fake, _) = planted();
1101        fake.answer("ResumeMicrovm", Answer::throttled_as_400())
1102            .answer("ResumeMicrovm", terminated_refusal())
1103            .answer(
1104                "GetMicrovm",
1105                Answer::ok(fake::microvm_response("TERMINATED", None)),
1106            );
1107        let ended = plane.resume("mvm-one").await.unwrap_err();
1108        assert_eq!(ended.kind(), ErrorKind::WindowClosed);
1109        assert_eq!(ended.attempt_count(), 2);
1110        assert!(!ended.backoff().is_zero());
1111        assert!(!ended.throttled());
1112    }
1113
1114    /// **TRAP-5, the boundary.** 4096 bytes passes, 4097 is rejected — inclusive, as
1115    /// measured 2026-08-07.
1116    ///
1117    /// **Falsification** — change the comparison to `<` and the 4096 case fails; change the
1118    /// ceiling to 16384 (the number the docs and the model's own documentation string claim)
1119    /// and the 4097 case fails.
1120    #[test]
1121    fn the_payload_ceiling_is_four_thousand_ninety_six_bytes_inclusive() {
1122        let at_ceiling = "a".repeat(4096);
1123        let payload = RunHookPayload::new(at_ceiling.clone()).expect("4096 bytes fits");
1124        assert_eq!(payload.len(), 4096);
1125        assert_eq!(payload.as_str(), at_ceiling);
1126
1127        let over = "a".repeat(4097);
1128        let error = RunHookPayload::new(over).expect_err("4097 bytes does not fit");
1129        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1130        let message = error.to_string();
1131        assert!(message.contains("4097 bytes"), "{message}");
1132        assert!(
1133            message.contains("ceiling of 4096"),
1134            "the message must name the service-model ceiling: {message}"
1135        );
1136        assert!(message.contains("2025-09-09"), "{message}");
1137        assert!(
1138            message.contains("wrong by 4x"),
1139            "the 16 KB claim has to be named as wrong: {message}"
1140        );
1141    }
1142
1143    /// **TRAP-5, the local-refusal half.** An over-ceiling payload means the fake records
1144    /// **zero** calls: the launch is refused before any control-plane call.
1145    #[tokio::test]
1146    async fn an_over_ceiling_payload_reaches_no_control_plane_call() {
1147        let (plane, fake, _) = planted();
1148
1149        // The payload type refuses first, so there is no way to even build the request.
1150        let refused = RunHookPayload::new("a".repeat(4097));
1151        assert!(refused.is_err());
1152        assert_eq!(fake.calls().len(), 0);
1153
1154        // And the at-ceiling one does launch, so the guard is not simply refusing
1155        // everything.
1156        fake.answer(
1157            "RunMicrovm",
1158            Answer::ok(fake::microvm_response("PENDING", None)),
1159        );
1160        let payload = RunHookPayload::new("a".repeat(4096)).expect("fits");
1161        plane
1162            .run_microvm(RunMicrovmRequest::new("arn:image", payload))
1163            .await
1164            .expect("launches");
1165        assert_eq!(fake.call_count("RunMicrovm"), 1);
1166    }
1167
1168    /// The ceiling is measured in **bytes**, not characters. A payload of 2049 two-byte
1169    /// characters is 4098 bytes and must be refused even though it is 2049 characters.
1170    #[test]
1171    fn the_ceiling_counts_utf8_bytes_rather_than_characters() {
1172        let two_byte_chars = "é".repeat(2049);
1173        assert_eq!(
1174            two_byte_chars.chars().count(),
1175            2049,
1176            "well under 4096 chars"
1177        );
1178        assert_eq!(two_byte_chars.len(), 4098, "but over 4096 bytes");
1179
1180        let error = RunHookPayload::new(two_byte_chars).expect_err("bytes are what count");
1181        assert!(error.to_string().contains("4098 bytes"), "{error}");
1182
1183        // 2048 of them is exactly 4096 bytes, which fits.
1184        let exact = "é".repeat(2048);
1185        assert_eq!(exact.len(), 4096);
1186        RunHookPayload::new(exact).expect("exactly at the ceiling");
1187    }
1188
1189    /// The identity material costs exactly its declared budget, and a launch without it is
1190    /// byte-for-byte what the older constructors built.
1191    ///
1192    /// Measured by difference rather than trusted from the constant's comment, the same
1193    /// discipline `env_contribution` applies: the ceiling is on the serialized string, so
1194    /// only the serialized string can say what the material costs. The compatibility half
1195    /// matters just as much — a pinned daemon that has never heard of identity must see no
1196    /// change from a client that did not ask for it.
1197    #[test]
1198    fn the_identity_material_costs_its_declared_budget_and_absence_costs_nothing() {
1199        let env = std::collections::HashMap::new();
1200        let without = RunHookPayload::for_launch("tok", &env).expect("fits");
1201        let none_asked = RunHookPayload::for_launch_with_identity("tok", &env, None).expect("fits");
1202        assert_eq!(
1203            without.as_str(),
1204            none_asked.as_str(),
1205            "no identity must produce byte-for-byte the payload this client always sent"
1206        );
1207
1208        let identity = crate::identity::LaunchIdentity::from_seeds([7_u8; 32], [9_u8; 32])
1209            .expect("valid seeds");
1210        let with =
1211            RunHookPayload::for_launch_with_identity("tok", &env, Some(&identity)).expect("fits");
1212        assert_eq!(
1213            with.len() - without.len(),
1214            protocol::identity::IDENTITY_PAYLOAD_BYTES,
1215            "the budget constant must equal what the material really costs on the wire"
1216        );
1217
1218        // And the payload parses as the daemon parses it, with both halves present.
1219        let parsed = protocol::hook::RunHook::parse(with.as_str()).expect("the daemon can read it");
1220        assert_eq!(
1221            parsed.identity_seed.as_deref(),
1222            Some(identity.seed_field().as_str())
1223        );
1224        assert_eq!(
1225            parsed.identity_host_public_key.as_deref(),
1226            Some(identity.host_public_field().as_str())
1227        );
1228    }
1229
1230    /// The agent-token payload is the shape the daemon parses, and an oversized token is
1231    /// caught even though the client builds the JSON — because the token is caller-supplied.
1232    #[test]
1233    fn the_agent_token_payload_is_checked_even_though_the_client_builds_it() {
1234        let payload = RunHookPayload::for_agent_token("bearer-token-value").expect("fits");
1235        assert_eq!(payload.as_str(), r#"{"agent_token":"bearer-token-value"}"#);
1236
1237        // A caller passing a credential bundle rather than a bearer token is who this
1238        // catches.
1239        let huge = "x".repeat(4096);
1240        let error = RunHookPayload::for_agent_token(&huge)
1241            .expect_err("the JSON wrapper pushes it over the ceiling");
1242        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1243        assert!(
1244            error.to_string().contains("one bearer token fits"),
1245            "{error}"
1246        );
1247    }
1248
1249    /// An empty launch env produces byte-for-byte the payload the token-only
1250    /// constructor always produced, so a caller who never passes one is unaffected by
1251    /// the field existing — and a daemon baked into an older image sees no new key.
1252    ///
1253    /// **Falsification** — always emit `"env"` and this fails on both assertions.
1254    #[test]
1255    fn an_empty_launch_env_emits_the_payload_the_token_only_constructor_did() {
1256        let with_empty = RunHookPayload::for_launch("tok", &std::collections::HashMap::new())
1257            .expect("an empty env fits");
1258        let token_only = RunHookPayload::for_agent_token("tok").expect("fits");
1259        assert_eq!(with_empty.as_str(), token_only.as_str());
1260        assert_eq!(with_empty.as_str(), r#"{"agent_token":"tok"}"#);
1261        assert!(
1262            !with_empty.as_str().contains("env"),
1263            "an empty env must not appear on the wire at all: {}",
1264            with_empty.as_str()
1265        );
1266    }
1267
1268    /// A launch env is carried in the payload the daemon parses.
1269    #[test]
1270    fn a_launch_env_rides_in_the_payload_the_daemon_parses() {
1271        let mut env = std::collections::HashMap::new();
1272        env.insert("ANTHROPIC_BASE_URL".to_string(), "https://x".to_string());
1273        let payload = RunHookPayload::for_launch("tok", &env).expect("fits");
1274
1275        // Parsed rather than string-compared, since a HashMap has no key order.
1276        let value: serde_json::Value =
1277            serde_json::from_str(payload.as_str()).expect("the payload is JSON");
1278        assert_eq!(value["agent_token"], "tok");
1279        assert_eq!(value["env"]["ANTHROPIC_BASE_URL"], "https://x");
1280    }
1281
1282    /// **The local 4096-byte refusal for a launch env.**
1283    ///
1284    /// The reason this is the case worth its own test rather than a repeat of the
1285    /// ceiling test above: one bearer token has always fit with room to spare, so
1286    /// before `env` existed the ceiling was effectively unreachable through the typed
1287    /// constructor. A launch env is what makes it reachable, and botocore does not
1288    /// enforce it client-side — the oversized request goes to the wire and comes back
1289    /// as a `ValidationException` on a member the caller did not know they were
1290    /// filling (`docs/PLATFORM.md`). So this refusal is the only local signal there is.
1291    ///
1292    /// The message must carry the byte count, the ceiling, and the env's share of it.
1293    /// Without the share, a caller reading "4128 bytes, ceiling 4096" cannot tell
1294    /// whether to shorten the token or drop a variable.
1295    ///
1296    /// **Falsification** — return `Ok` from `for_launch` without calling `new`, or drop
1297    /// the byte-count clause from the message, and this goes red on the specific
1298    /// assertion.
1299    #[test]
1300    fn an_over_ceiling_launch_env_is_refused_locally_with_the_byte_count() {
1301        let mut env = std::collections::HashMap::new();
1302        // Comfortably over on its own, the way a set of session credentials is.
1303        env.insert("CREDENTIALS".to_string(), "c".repeat(4096));
1304
1305        // The expected figure is derived from the same serialization the constructor
1306        // does rather than written as a literal, because a literal here asserts my
1307        // arithmetic about JSON framing and the point is the *count*, not the framing.
1308        let expected = serde_json::json!({ "agent_token": "tok", "env": &env })
1309            .to_string()
1310            .len();
1311        assert!(
1312            expected > 4096,
1313            "the fixture must actually be over: {expected}"
1314        );
1315
1316        let error = RunHookPayload::for_launch("tok", &env)
1317            .expect_err("a credential-scale launch env does not fit");
1318        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1319        let message = error.to_string();
1320
1321        assert!(
1322            message.contains("ceiling of 4096"),
1323            "the ceiling has to be named: {message}"
1324        );
1325        assert!(
1326            message.contains(&format!("{expected} bytes")),
1327            "the actual byte count has to be named: {message}"
1328        );
1329        assert!(
1330            message.contains("launch env contributed"),
1331            "the env's share of the budget is the actionable half: {message}"
1332        );
1333        assert!(
1334            message.contains("1 variable(s)"),
1335            "the variable count has to be named: {message}"
1336        );
1337    }
1338
1339    /// The refusal fires on the *serialized* payload, so a caller who measured their own
1340    /// values would be measuring the wrong thing.
1341    ///
1342    /// Two variables whose raw bytes sum to 4060 — under the ceiling — go over it once
1343    /// the JSON framing, the key names, and the quotes are counted. A check on the map
1344    /// rather than on the string would accept this and the launch would fail at AWS.
1345    #[test]
1346    fn the_launch_env_ceiling_counts_the_serialized_payload_and_not_the_raw_values() {
1347        let mut env = std::collections::HashMap::new();
1348        env.insert("A".to_string(), "a".repeat(2030));
1349        env.insert("B".to_string(), "b".repeat(2030));
1350        let raw: usize = env.values().map(String::len).sum();
1351        assert_eq!(raw, 4060, "the raw values are under the ceiling");
1352        assert!(
1353            raw <= crate::constants::MAX_RUN_HOOK_PAYLOAD_BYTES,
1354            "the fixture only means something if the raw sum fits: {raw}"
1355        );
1356
1357        let error = RunHookPayload::for_launch("tok", &env)
1358            .expect_err("the serialized payload is over even though the raw values are not");
1359        assert!(error.to_string().contains("2 variable(s)"), "{error}");
1360
1361        // And the boundary is still inclusive with an env in play: trimmed to fit, the
1362        // same shape succeeds — so the refusal is a ceiling and not a blanket ban.
1363        let mut fits = std::collections::HashMap::new();
1364        fits.insert("A".to_string(), "a".repeat(2000));
1365        fits.insert("B".to_string(), "b".repeat(2000));
1366        let payload = RunHookPayload::for_launch("tok", &fits).expect("4000-odd bytes fits");
1367        assert!(payload.len() <= 4096, "{}", payload.len());
1368    }
1369
1370    /// An empty payload is legal: the model's minimum is 0.
1371    #[test]
1372    fn an_empty_payload_is_legal() {
1373        let payload = RunHookPayload::new("").expect("min is 0");
1374        assert!(payload.is_empty());
1375        assert_eq!(payload.len(), 0);
1376    }
1377
1378    /// The launch request lands on the model's path with the model's member names, and the
1379    /// connectors are derived ARNs split across the two members (TRAP-4).
1380    #[tokio::test]
1381    async fn the_launch_emits_derived_connector_arns_in_the_right_members() {
1382        let (plane, fake, _) = planted();
1383        fake.answer(
1384            "RunMicrovm",
1385            Answer::ok(fake::microvm_response("PENDING", None)),
1386        );
1387
1388        let payload = RunHookPayload::for_agent_token("token").expect("fits");
1389        let request = RunMicrovmRequest::new("arn:image", payload).with_egress();
1390        plane.run_microvm(request).await.expect("launches");
1391
1392        let calls = fake.calls();
1393        assert_eq!(calls[0].path, "/2025-09-09/microvms");
1394        assert_eq!(calls[0].method, super::super::transport::Method::Post);
1395
1396        let body = fake.first_body("RunMicrovm");
1397        assert_eq!(
1398            body["ingressNetworkConnectors"],
1399            serde_json::json!([
1400                "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:ALL_INGRESS"
1401            ])
1402        );
1403        assert_eq!(
1404            body["egressNetworkConnectors"],
1405            serde_json::json!([
1406                "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:INTERNET_EGRESS"
1407            ])
1408        );
1409        assert_eq!(body["runHookPayload"], r#"{"agent_token":"token"}"#);
1410        assert_eq!(body["maximumDurationInSeconds"], 3600);
1411    }
1412
1413    /// Without egress, the member is **absent** — the request names no connector. What
1414    /// the platform does with that omission is a measurement, not this test's claim:
1415    /// `docs/PLATFORM.md`, "A VM launched without the egress connector still has
1416    /// outbound network" (2026-09-11 and 2026-09-12).
1417    #[tokio::test]
1418    async fn a_launch_without_egress_omits_the_member_entirely() {
1419        let (plane, fake, _) = planted();
1420        fake.answer(
1421            "RunMicrovm",
1422            Answer::ok(fake::microvm_response("PENDING", None)),
1423        );
1424        let payload = RunHookPayload::for_agent_token("token").expect("fits");
1425        plane
1426            .run_microvm(RunMicrovmRequest::new("arn:image", payload))
1427            .await
1428            .expect("launches");
1429
1430        let body = fake.first_body("RunMicrovm");
1431        assert!(
1432            body.get("egressNetworkConnectors").is_none(),
1433            "omitting egress is the whole mechanism: {body}"
1434        );
1435        assert!(body.get("ingressNetworkConnectors").is_some());
1436    }
1437
1438    #[tokio::test]
1439    async fn customer_vpc_connector_arns_reach_the_egress_member_unchanged() {
1440        let (plane, fake, _) = planted();
1441        fake.answer(
1442            "RunMicrovm",
1443            Answer::ok(fake::microvm_response("PENDING", None)),
1444        );
1445        let payload = RunHookPayload::for_agent_token("token").expect("fits");
1446        let mut request = RunMicrovmRequest::new("arn:image", payload);
1447        let arns: Vec<String> = (0..10)
1448            .map(|id| format!("arn:aws:lambda:us-east-1:123456789012:network-connector:vpc-{id}:1"))
1449            .collect();
1450        request.egress_network_connectors = arns.clone();
1451        plane
1452            .run_microvm(request)
1453            .await
1454            .expect("ten egress connectors plus ingress fits");
1455        let body = fake.first_body("RunMicrovm");
1456        assert_eq!(body["egressNetworkConnectors"], serde_json::json!(arns));
1457        assert_eq!(
1458            body["ingressNetworkConnectors"].as_array().unwrap().len(),
1459            1
1460        );
1461    }
1462
1463    #[tokio::test]
1464    async fn invalid_customer_egress_connectors_are_refused_before_launch() {
1465        let valid = "arn:aws:lambda:us-east-1:123456789012:network-connector:vpc-test";
1466        let invalid = [
1467            "VPC_EGRESS",
1468            "arn:aws:lambda:us-west-2:123456789012:network-connector:vpc-test",
1469            "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:INTERNET_EGRESS",
1470            "arn:aws:lambda:us-east-1:123456789012:network-connector:",
1471            "arn:aws:lambda:us-east-1:123456789012:network-connector:vpc-test:0",
1472            "arn:aws:lambda:us-east-1:123456789012:network-connector:vpc-test/other",
1473        ];
1474        for arn in invalid {
1475            let (plane, fake, _) = planted();
1476            let payload = RunHookPayload::for_agent_token("token").expect("fits");
1477            let error = plane
1478                .run_microvm(
1479                    RunMicrovmRequest::new("arn:image", payload).with_egress_network_connector(arn),
1480                )
1481                .await
1482                .expect_err(arn);
1483            assert_eq!(error.kind(), ErrorKind::InvalidArg);
1484            assert!(
1485                error.to_string().contains("egressNetworkConnectors"),
1486                "{error}"
1487            );
1488            assert!(fake.calls().is_empty());
1489        }
1490        for (internet, count) in [(true, 1), (false, 11)] {
1491            let (plane, fake, _) = planted();
1492            let payload = RunHookPayload::for_agent_token("token").expect("fits");
1493            let mut request = RunMicrovmRequest::new("arn:image", payload);
1494            request.egress_network_connectors = vec![valid.to_string(); count];
1495            if internet {
1496                request = request.with_egress();
1497            }
1498            let error = plane
1499                .run_microvm(request)
1500                .await
1501                .expect_err("invalid routing request");
1502            assert_eq!(error.kind(), ErrorKind::InvalidArg);
1503            assert!(fake.calls().is_empty());
1504        }
1505    }
1506
1507    /// **A pinned `imageVersion` reaches the wire, and an unpinned launch omits the member.**
1508    ///
1509    /// The absence half is the one that matters for compatibility: an unpinned launch has to
1510    /// emit byte-for-byte what this client always sent, so a `"imageVersion": null` on every
1511    /// launch would be a new member on a request that has worked for months. The presence half
1512    /// is what makes a canary a canary — the launch goes against the version named rather than
1513    /// against whatever became latest while it was starting.
1514    ///
1515    /// **Falsification** — run 2026-08-16. Drop the `image_version` assignment in
1516    /// `run_microvm` and the pinned assertion goes red with the member absent; remove
1517    /// `skip_serializing_if` from the wire field and the absence assertion goes red with a
1518    /// `null`.
1519    #[tokio::test]
1520    async fn a_pinned_launch_version_reaches_the_wire_and_an_unpinned_one_omits_the_member() {
1521        let (plane, fake, _) = planted();
1522        fake.answer(
1523            "RunMicrovm",
1524            Answer::ok(fake::microvm_response("PENDING", None)),
1525        );
1526        let payload = RunHookPayload::for_agent_token("token").expect("fits");
1527        plane
1528            .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_image_version("2.0"))
1529            .await
1530            .expect("launches");
1531
1532        let body = fake.first_body("RunMicrovm");
1533        assert_eq!(body["imageVersion"], "2.0");
1534        assert_eq!(
1535            body["imageIdentifier"], "arn:image",
1536            "pinning a version does not replace the identifier; both are sent"
1537        );
1538
1539        let (plane, fake, _) = planted();
1540        fake.answer(
1541            "RunMicrovm",
1542            Answer::ok(fake::microvm_response("PENDING", None)),
1543        );
1544        let payload = RunHookPayload::for_agent_token("token").expect("fits");
1545        plane
1546            .run_microvm(RunMicrovmRequest::new("arn:image", payload))
1547            .await
1548            .expect("launches");
1549        assert!(
1550            fake.first_body("RunMicrovm").get("imageVersion").is_none(),
1551            "an unpinned launch must send what this client always sent: {}",
1552            fake.first_body("RunMicrovm")
1553        );
1554    }
1555
1556    /// An invalid `imageVersion` is refused before any call — including on the launch path,
1557    /// which is where a rollback happens.
1558    ///
1559    /// A `ValidationException` about the request rather than about the version, arriving at the
1560    /// moment someone is re-pinning away from a bad build, is the least useful failure
1561    /// available. So the `Version` shape's three constraints are checked here.
1562    ///
1563    /// **Falsification** — run 2026-08-16. Delete the `require_valid_version` call from
1564    /// `run_microvm` and the zero-call assertion goes red.
1565    #[tokio::test]
1566    async fn an_invalid_launch_version_reaches_no_control_plane_call() {
1567        for bad in ["", "2.0\n", "a b"] {
1568            let (plane, fake, _) = planted();
1569            let payload = RunHookPayload::for_agent_token("token").expect("fits");
1570            let error = plane
1571                .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_image_version(bad))
1572                .await
1573                .expect_err(&format!("{bad:?} is not a legal Version"));
1574            assert_eq!(error.kind(), ErrorKind::InvalidArg, "{bad:?}");
1575            assert!(
1576                error.to_string().contains("imageVersion"),
1577                "{bad:?}: {error}"
1578            );
1579            assert_eq!(fake.calls().len(), 0, "{bad:?} must not reach the wire");
1580        }
1581    }
1582
1583    /// The connectors reaching the wire are ARNs, never bare names — a bare name is rejected
1584    /// with "Malformed network connector ARN", and it is the value that reads most natural
1585    /// to write.
1586    #[tokio::test]
1587    async fn no_bare_connector_name_reaches_the_wire() {
1588        let (plane, fake, _) = planted();
1589        fake.answer(
1590            "RunMicrovm",
1591            Answer::ok(fake::microvm_response("PENDING", None)),
1592        );
1593        let payload = RunHookPayload::for_agent_token("token").expect("fits");
1594        plane
1595            .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_egress())
1596            .await
1597            .expect("launches");
1598
1599        let body = fake.first_body("RunMicrovm");
1600        for member in ["ingressNetworkConnectors", "egressNetworkConnectors"] {
1601            for value in body[member].as_array().expect("a list") {
1602                let text = value.as_str().expect("a string");
1603                assert!(text.starts_with("arn:aws:lambda:"), "{member}: {text}");
1604                assert_ne!(text, "ALL_INGRESS");
1605                assert_ne!(text, "INTERNET_EGRESS");
1606            }
1607        }
1608    }
1609
1610    /// An out-of-range duration is refused before any call.
1611    #[tokio::test]
1612    async fn an_out_of_range_duration_reaches_no_control_plane_call() {
1613        let (plane, fake, _) = planted();
1614        let payload = RunHookPayload::for_agent_token("token").expect("fits");
1615        let mut request = RunMicrovmRequest::new("arn:image", payload);
1616        request.max_duration_sec = 28_801;
1617
1618        let error = plane
1619            .run_microvm(request)
1620            .await
1621            .expect_err("over the ceiling");
1622        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1623        assert_eq!(fake.calls().len(), 0);
1624    }
1625
1626    /// **TRAP-8.** A VM that reaches a terminal state before RUNNING is rejected with the
1627    /// state **and** the `stateReason` in the message.
1628    ///
1629    /// The message is asserted rather than merely the error, because a timeout also produces
1630    /// an error — so "an error occurred" would pass against the exact defect this closes.
1631    ///
1632    /// **Falsification** — remove the `fail_on` branch from `wait_for_state` and this test
1633    /// fails with an `ErrorKind::Timeout` whose message names neither the state nor the
1634    /// reason. Verified; see the packet's guard proofs.
1635    #[tokio::test]
1636    async fn a_terminal_state_before_running_carries_both_the_state_and_the_reason() {
1637        let (plane, fake, _) = planted();
1638        fake.answer(
1639            "GetMicrovm",
1640            Answer::ok(fake::microvm_response(
1641                "TERMINATED",
1642                Some("run hook returned 500"),
1643            )),
1644        );
1645
1646        let error = plane
1647            .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1648            .await
1649            .expect_err("a dead VM must not be waited out");
1650
1651        assert_eq!(error.kind(), ErrorKind::LaunchDied);
1652        assert_eq!(error.code(), "ERR_LAUNCH_DIED");
1653        let message = error.to_string();
1654        assert!(
1655            message.contains("TERMINATED"),
1656            "the state must be attached: {message}"
1657        );
1658        assert!(
1659            message.contains("run hook returned 500"),
1660            "the stateReason must be attached: {message}"
1661        );
1662        assert!(
1663            message.contains("lifecycle hook failed"),
1664            "the likely cause is worth naming: {message}"
1665        );
1666        assert!(
1667            fake.call_count("GetMicrovm") == 1,
1668            "it fails fast rather than polling to the deadline"
1669        );
1670    }
1671
1672    /// Every terminal state fails the launch fast, not just TERMINATED. SUSPENDED reached
1673    /// before RUNNING is also a death — the VM never came up.
1674    ///
1675    /// The states are read from `constants::TERMINAL_STATES` rather than listed, so a state
1676    /// added there is covered here without a second edit — and the count is asserted so the
1677    /// loop cannot silently become empty.
1678    #[tokio::test]
1679    async fn every_terminal_state_fails_a_launch_fast() {
1680        assert_eq!(crate::constants::TERMINAL_STATES.len(), 4);
1681        for state in crate::constants::TERMINAL_STATES {
1682            let (plane, fake, _) = planted();
1683            fake.answer(
1684                "GetMicrovm",
1685                Answer::ok(fake::microvm_response(state, Some("hook timeout"))),
1686            );
1687            let error = plane
1688                .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1689                .await
1690                .expect_err(&format!("{state} before RUNNING is a death"));
1691            assert_eq!(error.kind(), ErrorKind::LaunchDied, "{state}");
1692            assert!(error.to_string().contains(state), "{state}: {error}");
1693            assert!(
1694                error.to_string().contains("hook timeout"),
1695                "{state}: {error}"
1696            );
1697            assert_eq!(
1698                fake.call_count("GetMicrovm"),
1699                1,
1700                "{state} must fail fast rather than polling to the deadline"
1701            );
1702        }
1703    }
1704
1705    /// A terminal state with **no** `stateReason` says so rather than printing an empty
1706    /// string: "the service said nothing" is a different diagnosis from "the service said
1707    /// nothing useful".
1708    #[tokio::test]
1709    async fn a_missing_state_reason_is_named_rather_than_printed_empty() {
1710        let (plane, fake, _) = planted();
1711        fake.answer(
1712            "GetMicrovm",
1713            Answer::ok(fake::microvm_response("TERMINATED", None)),
1714        );
1715
1716        let error = plane
1717            .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1718            .await
1719            .expect_err("still a death");
1720        let message = error.to_string();
1721        assert!(message.contains("no stateReason"), "{message}");
1722        // The failure this guards against is an absent reason rendering as an empty string,
1723        // which reads as the service having answered with nothing rather than not having
1724        // answered. `: .` is what that looks like in this message's shape.
1725        assert!(
1726            !message.contains(r#"["RUNNING"]: ."#),
1727            "an absent reason must not render as an empty string: {message}"
1728        );
1729        assert_eq!(error.kind(), ErrorKind::LaunchDied);
1730    }
1731
1732    /// The wait returns on RUNNING, having polled through PENDING.
1733    #[tokio::test]
1734    async fn the_launch_wait_polls_through_pending_and_returns_on_running() {
1735        let (plane, fake, _) = planted();
1736        fake.answer(
1737            "GetMicrovm",
1738            Answer::ok(fake::microvm_response("PENDING", None)),
1739        )
1740        .answer(
1741            "GetMicrovm",
1742            Answer::ok(fake::microvm_response("RUNNING", None)),
1743        );
1744
1745        let vm = plane
1746            .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1747            .await
1748            .expect("reaches RUNNING");
1749        assert_eq!(vm.state, "RUNNING");
1750        assert_eq!(
1751            vm.endpoint,
1752            "https://mvm-abc123.microvm.us-east-1.amazonaws.com"
1753        );
1754        assert_eq!(fake.call_count("GetMicrovm"), 2);
1755    }
1756
1757    /// A launch that never comes up ends at its deadline with a `Timeout` — which is a
1758    /// *different* error from TRAP-8's, and the distinction is the whole reason the TRAP-8
1759    /// test asserts on the message.
1760    #[tokio::test]
1761    async fn a_launch_that_never_comes_up_times_out_rather_than_reporting_a_death() {
1762        let (plane, fake, _) = planted();
1763        fake.answer(
1764            "GetMicrovm",
1765            Answer::ok(fake::microvm_response("PENDING", None)),
1766        );
1767
1768        let error = plane
1769            .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1770            .await
1771            .expect_err("the deadline elapses");
1772        assert_eq!(error.kind(), ErrorKind::Timeout);
1773        assert_ne!(
1774            error.kind(),
1775            ErrorKind::LaunchDied,
1776            "a timeout is not a death, and conflating them is what hid the cause"
1777        );
1778        assert!(error.to_string().contains("PENDING"), "{error}");
1779    }
1780
1781    /// The resume path fails fast on the **dead** states and waits through SUSPENDED —
1782    /// which is the state a resume is called from, so failing on it would fail every resume.
1783    #[tokio::test]
1784    async fn the_resume_wait_tolerates_suspended_and_fails_on_terminated() {
1785        let (plane, fake, _) = planted();
1786        fake.answer(
1787            "GetMicrovm",
1788            Answer::ok(fake::microvm_response("SUSPENDED", None)),
1789        )
1790        .answer(
1791            "GetMicrovm",
1792            Answer::ok(fake::microvm_response("RUNNING", None)),
1793        );
1794
1795        let vm = plane
1796            .wait_for_state(
1797                "mvm-abc123",
1798                &["RUNNING"],
1799                &crate::constants::DEAD_STATES,
1800                WaitOpts::for_launch(),
1801            )
1802            .await
1803            .expect("SUSPENDED is a waypoint on the resume path");
1804        assert_eq!(vm.state, "RUNNING");
1805
1806        let (plane, fake, _) = planted();
1807        fake.answer(
1808            "GetMicrovm",
1809            Answer::ok(fake::microvm_response(
1810                "TERMINATED",
1811                Some("suspended window elapsed"),
1812            )),
1813        );
1814        let error = plane
1815            .wait_for_state(
1816                "mvm-abc123",
1817                &["RUNNING"],
1818                &crate::constants::DEAD_STATES,
1819                WaitOpts::for_launch(),
1820            )
1821            .await
1822            .expect_err("a terminated VM never reaches RUNNING");
1823        assert_eq!(error.kind(), ErrorKind::LaunchDied);
1824        assert!(
1825            error.to_string().contains("suspended window elapsed"),
1826            "{error}"
1827        );
1828    }
1829
1830    /// A suspend **wants** TERMINATED as well as SUSPENDED: a VM that dies while suspending
1831    /// is a state to report, not an exception out of a teardown.
1832    #[tokio::test]
1833    async fn a_suspend_treats_terminated_as_a_state_to_report() {
1834        let (plane, fake, _) = planted();
1835        fake.answer(
1836            "GetMicrovm",
1837            Answer::ok(fake::microvm_response("TERMINATED", None)),
1838        );
1839
1840        let vm = plane
1841            .wait_for_state("mvm-abc123", &SUSPEND_WANTED, &[], WaitOpts::for_launch())
1842            .await
1843            .expect("a suspend reports the state it reached");
1844        assert_eq!(vm.state, "TERMINATED");
1845    }
1846
1847    /// The lifecycle calls land on the model's paths and methods. `POST` for suspend and
1848    /// resume, `DELETE` for terminate — transcribed from each operation's `http` trait.
1849    #[tokio::test]
1850    async fn the_lifecycle_calls_use_the_models_paths_and_methods() {
1851        let (plane, fake, _) = planted();
1852        fake.answer("SuspendMicrovm", Answer::ok(fake::empty_response()))
1853            .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1854            .answer("TerminateMicrovm", Answer::ok(fake::empty_response()));
1855
1856        plane.suspend("mvm-1").await.expect("suspends");
1857        plane.resume("mvm-1").await.expect("resumes");
1858        plane.terminate("mvm-1").await.expect("terminates");
1859
1860        let calls = fake.calls();
1861        use super::super::transport::Method;
1862        assert_eq!(
1863            calls
1864                .iter()
1865                .map(|call| (call.operation, call.method, call.path.as_str()))
1866                .collect::<Vec<_>>(),
1867            [
1868                (
1869                    "SuspendMicrovm",
1870                    Method::Post,
1871                    "/2025-09-09/microvms/mvm-1/suspend"
1872                ),
1873                (
1874                    "ResumeMicrovm",
1875                    Method::Post,
1876                    "/2025-09-09/microvms/mvm-1/resume"
1877                ),
1878                (
1879                    "TerminateMicrovm",
1880                    Method::Delete,
1881                    "/2025-09-09/microvms/mvm-1"
1882                ),
1883            ]
1884        );
1885    }
1886
1887    /// `ResumeMicrovm`'s refusal of a VM the service terminated, as it answered on 2026-10-12
1888    /// in us-east-1 (#597): a 400 whose error type is `ValidationException`.
1889    fn terminated_refusal() -> Answer {
1890        Answer {
1891            status: 400,
1892            body: r#"{"message": "The MicroVM mvm-abc123 has been terminated and its state cannot be changed."}"#
1893                .to_string(),
1894            error_type: Some("ValidationException".to_string()),
1895        }
1896    }
1897
1898    /// **STATE-12 at the control plane.** A resume the service refuses for a VM `GetMicrovm`
1899    /// reports in either dead state is `WindowClosed`, naming the state and its `stateReason`.
1900    /// The CLI's `resume`, both bindings' `ControlPlane.resume` and `Sandbox::resume` call
1901    /// this, so every resume that reaches the service reports an ended VM the same way. A
1902    /// `Sandbox` whose VM the idle policy suspended still holds it as RUNNING, and its resume
1903    /// is refused from RUNNING before any call: that suspend reaches a caller only through
1904    /// `Sandbox::adopt`, which reads the state from the service, or `ControlPlane::resume`,
1905    /// which holds no lifecycle to refuse from.
1906    #[tokio::test]
1907    async fn a_resume_refused_for_a_vm_that_terminated_while_suspended_is_window_closed() {
1908        for state in crate::constants::DEAD_STATES {
1909            let (plane, fake, _) = planted();
1910            fake.answer("ResumeMicrovm", terminated_refusal()).answer(
1911                "GetMicrovm",
1912                Answer::ok(fake::microvm_response(
1913                    state,
1914                    Some("MicroVM exceeded maximum lifetime."),
1915                )),
1916            );
1917
1918            let error = plane
1919                .resume("mvm-abc123")
1920                .await
1921                .expect_err("the VM is gone");
1922
1923            assert_eq!(error.kind(), ErrorKind::WindowClosed, "{state}: {error}");
1924            let message = error.to_string();
1925            assert!(message.contains(state), "{state}: {message}");
1926            assert!(
1927                message.contains("MicroVM exceeded maximum lifetime."),
1928                "{state}: the stateReason: {message}"
1929            );
1930            assert_eq!(
1931                fake.operations(),
1932                ["ResumeMicrovm", "GetMicrovm"],
1933                "{state}: one refused resume, then one read"
1934            );
1935        }
1936    }
1937
1938    /// **STATE-12 for a VM a terminate ended.** On 2026-10-12 in us-east-1, `GetMicrovm` on a
1939    /// VM `microvm terminate` ended answered TERMINATED with `stateReason` `Success.` about
1940    /// 4 h later, and `ResumeMicrovm` refused it with the same `ValidationException` as a VM
1941    /// the service ended. The error states what the client knows, the state, the
1942    /// `stateReason` and the refusal, and names no suspended window it can't know about. The
1943    /// refusal is the error's source, and the `stateReason`'s own period isn't doubled.
1944    #[tokio::test]
1945    async fn a_resume_refused_for_a_vm_a_terminate_ended_names_no_cause_and_keeps_the_refusal() {
1946        let (plane, fake, _) = planted();
1947        fake.answer("ResumeMicrovm", terminated_refusal()).answer(
1948            "GetMicrovm",
1949            Answer::ok(fake::microvm_response("TERMINATED", Some("Success."))),
1950        );
1951
1952        let error = plane
1953            .resume("mvm-abc123")
1954            .await
1955            .expect_err("the VM is gone");
1956
1957        assert_eq!(error.kind(), ErrorKind::WindowClosed, "{error}");
1958        let message = error.to_string();
1959        assert_eq!(
1960            message,
1961            "microvm mvm-abc123 can't be resumed: the service refused ResumeMicrovm, and \
1962             GetMicrovm reports the VM TERMINATED (Success.). A VM that has ended doesn't come \
1963             back; launch a new one."
1964        );
1965        assert!(!message.contains(".."), "{message}");
1966        assert!(
1967            !message.contains("suspended"),
1968            "no cause it can't know: {message}"
1969        );
1970
1971        let source = std::error::Error::source(&error).expect("the refusal is the source");
1972        let refused = source
1973            .downcast_ref::<Error>()
1974            .expect("the source is the service's refusal");
1975        assert_eq!(refused.kind(), ErrorKind::Platform);
1976        assert!(
1977            refused
1978                .to_string()
1979                .contains("has been terminated and its state cannot be changed"),
1980            "the service's own text: {refused}"
1981        );
1982    }
1983
1984    /// A refused resume of a VM `GetMicrovm` reports alive, or whose state that read can't
1985    /// get, comes back as the service's refusal: only a VM reported dead is `WindowClosed`.
1986    #[tokio::test]
1987    async fn a_resume_refused_for_a_live_or_unreadable_vm_keeps_the_services_answer() {
1988        let (plane, fake, _) = planted();
1989        fake.answer(
1990            "ResumeMicrovm",
1991            Answer::failure(409, "The MicroVM is changing state."),
1992        )
1993        .answer(
1994            "GetMicrovm",
1995            Answer::ok(fake::microvm_response("SUSPENDED", None)),
1996        );
1997        let error = plane
1998            .resume("mvm-abc123")
1999            .await
2000            .expect_err("the service refused");
2001        assert_eq!(error.kind(), ErrorKind::Platform, "{error}");
2002        assert!(error.to_string().contains("ConflictException"), "{error}");
2003
2004        let (plane, fake, _) = planted();
2005        fake.answer("ResumeMicrovm", terminated_refusal())
2006            .answer("GetMicrovm", Answer::failure(404, "not found"));
2007        let error = plane
2008            .resume("mvm-abc123")
2009            .await
2010            .expect_err("the service refused");
2011        assert_eq!(error.kind(), ErrorKind::Platform, "{error}");
2012        let message = error.to_string();
2013        assert!(
2014            message.contains("ResumeMicrovm failed") && !message.contains("GetMicrovm"),
2015            "the resume's refusal rather than the read's: {message}"
2016        );
2017    }
2018
2019    /// A resume denied for its credentials is not the service's word on the VM, so no state
2020    /// is read and the denial comes back as it was.
2021    #[tokio::test]
2022    async fn a_resume_denied_for_its_credentials_reads_no_state() {
2023        let (plane, fake, _) = planted();
2024        fake.answer(
2025            "ResumeMicrovm",
2026            Answer::failure(403, "User is not authorized to perform ResumeMicrovm"),
2027        )
2028        .answer(
2029            "GetMicrovm",
2030            Answer::ok(fake::microvm_response("TERMINATED", None)),
2031        );
2032        let error = plane.resume("mvm-abc123").await.expect_err("denied");
2033        assert_eq!(error.kind(), ErrorKind::Credentials, "{error}");
2034        assert_eq!(fake.call_count("GetMicrovm"), 0);
2035    }
2036
2037    /// **TRAP-7.** The minted token is read out of the `authToken` **map**, and
2038    /// [`ProxyToken::headers`] emits both headers.
2039    #[tokio::test]
2040    async fn the_minted_token_comes_from_the_map_and_carries_both_headers() {
2041        let (plane, fake, _) = planted();
2042        fake.answer(
2043            "CreateMicrovmAuthToken",
2044            Answer::ok(fake::auth_token_response("opaque-proxy-token")),
2045        );
2046
2047        let token = plane.mint_auth_token("mvm-1").await.expect("mints");
2048        assert_eq!(token.value(), "opaque-proxy-token");
2049        assert_eq!(token.port(), 9000);
2050
2051        let headers = token.headers();
2052        assert_eq!(
2053            headers[0],
2054            ("X-aws-proxy-auth", "opaque-proxy-token".to_string())
2055        );
2056        assert_eq!(
2057            headers[1],
2058            ("X-aws-proxy-port", "9000".to_string()),
2059            "the auth header without the port header is rejected in a way that reads like a \
2060             bad token"
2061        );
2062
2063        let body = fake.first_body("CreateMicrovmAuthToken");
2064        assert_eq!(body["expirationInMinutes"], 60);
2065        assert_eq!(body["allowedPorts"][0]["port"], 9000);
2066    }
2067
2068    /// The shell mint posts to the model's `shell-auth-token` route with **only** the
2069    /// expiration in its body, and reads the credential out of the `authToken` map —
2070    /// the same `TokenParts` discipline as TRAP-7.
2071    ///
2072    /// The `allowedPorts` absence is asserted rather than assumed, because it is the
2073    /// measured heart of the operation: `CreateMicrovmShellAuthToken` takes no ports at
2074    /// all — the shell is not a port — and a client that sent them would be sending a
2075    /// member the model does not declare (`docs/PLATFORM.md`, 2026-08-15).
2076    ///
2077    /// **Falsification** — watched fail 2026-08-31: pointing the call at
2078    /// `paths::auth_token` fails the path assertion at the `assert_eq!` on `calls[0].path`.
2079    #[tokio::test]
2080    async fn the_shell_mint_names_its_own_route_and_sends_no_ports() {
2081        let (plane, fake, _) = planted();
2082        fake.answer(
2083            "CreateMicrovmShellAuthToken",
2084            Answer::ok(fake::auth_token_response("opaque-shell-jwe")),
2085        );
2086
2087        let token = plane
2088            .mint_shell_auth_token("mvm-1")
2089            .await
2090            .expect("mints a shell token");
2091        assert_eq!(token.value(), "opaque-shell-jwe");
2092
2093        let calls = fake.calls();
2094        assert_eq!(calls.len(), 1);
2095        assert_eq!(calls[0].operation, "CreateMicrovmShellAuthToken");
2096        assert_eq!(calls[0].path, "/2025-09-09/microvms/mvm-1/shell-auth-token");
2097
2098        let body = fake.first_body("CreateMicrovmShellAuthToken");
2099        assert_eq!(body["expirationInMinutes"], 60);
2100        assert_eq!(
2101            body.get("allowedPorts"),
2102            None,
2103            "the shell is not a port: the request declares no allowedPorts member"
2104        );
2105        assert_eq!(
2106            body.as_object().map(serde_json::Map::len),
2107            Some(1),
2108            "expirationInMinutes is the body's only member: {body}"
2109        );
2110    }
2111
2112    /// The shell mint refuses an empty identifier locally, like every other
2113    /// identifier-in-the-URI operation: an empty segment would address a route the model
2114    /// does not declare.
2115    #[tokio::test]
2116    async fn the_shell_mint_refuses_an_empty_identifier_before_the_wire() {
2117        let (plane, fake, _) = planted();
2118        let error = plane
2119            .mint_shell_auth_token("")
2120            .await
2121            .expect_err("an empty identifier is refused");
2122        assert_eq!(error.kind(), ErrorKind::InvalidArg);
2123        assert_eq!(fake.calls().len(), 0, "nothing may reach the wire");
2124    }
2125
2126    /// A launch request's `Debug` does not print the agent token.
2127    ///
2128    /// Asserted on the whole [`RunMicrovmRequest`] rather than on the payload alone,
2129    /// because that is the type a caller actually logs — and it keeps its derived `Debug`,
2130    /// so what is pinned here is that the derive inherits [`RunHookPayload`]'s hand-written
2131    /// one. **Falsification** — restore `#[derive(Debug)]` on [`RunHookPayload`] and the
2132    /// `SECRETTOKEN` assertion fails through the derived request `Debug`.
2133    #[test]
2134    fn a_launch_requests_debug_does_not_print_the_agent_token() {
2135        let payload = RunHookPayload::for_agent_token("SECRETTOKEN").expect("fits");
2136
2137        let rendered = format!("{payload:?}");
2138        assert!(
2139            !rendered.contains("SECRETTOKEN"),
2140            "the agent token reached a Debug string: {rendered}"
2141        );
2142        assert!(
2143            rendered.contains(&format!("{} bytes", payload.len())),
2144            "the size is what a TRAP-5 diagnosis needs: {rendered}"
2145        );
2146
2147        let request = RunMicrovmRequest::new("arn:image", payload);
2148        let rendered = format!("{request:?}");
2149        assert!(rendered.contains("arn:image"), "{rendered}");
2150        assert!(
2151            !rendered.contains("SECRETTOKEN"),
2152            "the agent token reached a Debug string through the request: {rendered}"
2153        );
2154    }
2155
2156    /// A minted token's `Debug` names its headers and never its value, so a logged
2157    /// launch does not log a credential.
2158    ///
2159    /// The mirror of `session::proxy`'s `a_proxy_token_debug_does_not_print_the_credential`,
2160    /// on the control-plane side of the conversion. **Falsification** — restore
2161    /// `#[derive(Debug)]` on [`ProxyToken`] and this fails on the `secret` assertion.
2162    #[test]
2163    fn a_proxy_token_debug_does_not_print_the_credential() {
2164        let mut map = std::collections::BTreeMap::new();
2165        map.insert(
2166            PROXY_AUTH_HEADER.to_string(),
2167            "eyJhbGciOi-secret".to_string(),
2168        );
2169        let token = ProxyToken::from_map(&map, 9000).expect("the map has the auth key");
2170
2171        let rendered = format!("{token:?}");
2172        assert!(rendered.contains(PROXY_AUTH_HEADER), "{rendered}");
2173        assert!(rendered.contains("9000"), "{rendered}");
2174        assert!(
2175            !rendered.contains("secret"),
2176            "the token value reached a Debug string: {rendered}"
2177        );
2178    }
2179
2180    /// A response whose map lacks the auth key is **retryable**, because minting happens
2181    /// inside the request retry path and a throttle mid-run must not kill a healthy trial.
2182    #[test]
2183    fn a_map_missing_the_auth_key_is_a_retryable_mint_failure() {
2184        let mut map = std::collections::BTreeMap::new();
2185        map.insert("SomeOtherKey".to_string(), "value".to_string());
2186
2187        let error = ProxyToken::from_map(&map, 9000).expect_err("no auth key");
2188        assert_eq!(
2189            error.wire_kind(),
2190            Some(crate::error::WireKind::AuthTokenMint)
2191        );
2192        assert!(error.retryable(), "minting is inside the retry path");
2193        assert!(
2194            error.to_string().contains("is a map, not a string"),
2195            "{error}"
2196        );
2197    }
2198
2199    /// The token is minted for the configured port, not always 9000.
2200    #[tokio::test]
2201    async fn the_token_is_minted_for_the_configured_port() {
2202        let fake = Arc::new(FakeControlPlane::new());
2203        let plane = crate::testing::control_plane(
2204            fake.clone(),
2205            Region::UsEast1,
2206            Arc::new(TestClock::new()),
2207        )
2208        .with_port(8080)
2209        .expect("8080 is a legal port");
2210        fake.answer(
2211            "CreateMicrovmAuthToken",
2212            Answer::ok(fake::auth_token_response("t")),
2213        );
2214
2215        let token = plane.mint_auth_token("mvm-1").await.expect("mints");
2216        assert_eq!(token.port(), 8080);
2217        assert_eq!(
2218            fake.first_body("CreateMicrovmAuthToken")["allowedPorts"][0]["port"],
2219            8080
2220        );
2221    }
2222
2223    /// **TRAP-11, revised, across a full lifecycle.** Drive create, wait, launch, wait,
2224    /// mint, suspend, resume, terminate, delete — and then assert the recorder saw
2225    /// **zero** shell-auth calls, and that the launch request carried **exactly the
2226    /// connectors the caller asked for**, nothing added and nothing dropped.
2227    ///
2228    /// The body scan used to assert zero `SHELL_INGRESS` anywhere, standing on the claim
2229    /// that no intent could name it. `ConnectorIntent::ShellIngress` exists now — the
2230    /// measured ground is that one interactive session is not programmatic exec, not
2231    /// that the connector is unspeakable (`docs/PLATFORM.md`, 2026-08-15) — so the
2232    /// honest assertion is set-equality against the request: a caller that asked for
2233    /// `[ALL_INGRESS]` plus egress gets that set on the wire and no other.
2234    ///
2235    /// The shell-auth scans stay as counts, and their ground moved once more:
2236    /// `mint_shell_auth_token` exists now (issue #69, `microvm shell`'s method), so the
2237    /// absence being pinned is no longer the method's but the **exec path's** — a
2238    /// lifecycle that asked for no shell drives everything exec can do and emits zero
2239    /// shell operations. Nothing here calls the shell mint, and that is the assertion.
2240    ///
2241    /// **Falsification** — make any lifecycle step (`run_microvm`, `mint_auth_token`,
2242    /// suspend/resume/terminate) also touch the shell route, or call
2243    /// `mint_shell_auth_token` from this lifecycle: the operation scan and the path scan
2244    /// both fail. Make `run_microvm` inject a connector the request did not carry: the
2245    /// set-equality assertions fail.
2246    #[tokio::test]
2247    async fn a_full_lifecycle_never_calls_shell_auth_and_requests_only_what_was_asked() {
2248        let (plane, fake, _) = planted();
2249        fake.answer(
2250            "CreateMicrovmImage",
2251            Answer::created(fake::create_image_response("img")),
2252        )
2253        .answer(
2254            "GetMicrovmImage",
2255            Answer::ok(fake::get_image_response("img", "CREATED")),
2256        )
2257        .answer(
2258            "RunMicrovm",
2259            Answer::ok(fake::microvm_response("PENDING", None)),
2260        )
2261        .answer(
2262            "GetMicrovm",
2263            Answer::ok(fake::microvm_response("RUNNING", None)),
2264        )
2265        .answer(
2266            "CreateMicrovmAuthToken",
2267            Answer::ok(fake::auth_token_response("proxy-token")),
2268        )
2269        .answer("SuspendMicrovm", Answer::ok(fake::empty_response()))
2270        .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
2271        .answer("TerminateMicrovm", Answer::ok(fake::empty_response()))
2272        .answer(
2273            "ListMicrovmImageVersions",
2274            Answer::ok(fake::list_versions_response("1")),
2275        )
2276        .answer(
2277            "DeleteMicrovmImage",
2278            Answer::ok(fake::delete_image_response()),
2279        );
2280
2281        // Everything this client can do, in the order a real run does it.
2282        let request = super::super::CreateImageRequest::new(
2283            "img",
2284            crate::testing::aarch64_daemon(b"binary"),
2285            "s3://bucket/img.zip",
2286            "arn:aws:iam::123456789012:role/build",
2287        );
2288        let image = plane.create_image(request).await.expect("creates");
2289        plane
2290            .wait_for_image(&image.identifier, image.size, WaitOpts::default())
2291            .await
2292            .expect("becomes usable");
2293
2294        let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
2295        let vm = plane
2296            .run_microvm(
2297                super::super::RunMicrovmRequest::new(&image.identifier, payload).with_egress(),
2298            )
2299            .await
2300            .expect("launches");
2301        plane
2302            .wait_for_running(&vm.id, WaitOpts::for_launch())
2303            .await
2304            .expect("reaches RUNNING");
2305        plane.mint_auth_token(&vm.id).await.expect("mints");
2306        plane.suspend(&vm.id).await.expect("suspends");
2307        plane.resume(&vm.id).await.expect("resumes");
2308        plane.terminate(&vm.id).await.expect("terminates");
2309        plane
2310            .delete_image(&image.identifier, 1, std::time::Duration::from_secs(1))
2311            .await;
2312
2313        // The lifecycle really ran, so the zero counts below are about absence rather than
2314        // about nothing having happened.
2315        let operations = fake.operations();
2316        assert!(
2317            operations.len() >= 10,
2318            "the lifecycle must actually have run: {operations:?}"
2319        );
2320        assert!(operations.contains(&"CreateMicrovmAuthToken"));
2321
2322        assert_eq!(
2323            fake.call_count("CreateMicrovmShellAuthToken"),
2324            0,
2325            "the shell mint exists for `microvm shell` alone, and a lifecycle that asked \
2326             for no shell must mint no shell token: {operations:?}"
2327        );
2328        for operation in &operations {
2329            assert!(
2330                !operation.contains("Shell"),
2331                "no operation may be a shell one: {operation}"
2332            );
2333        }
2334        for path in fake.paths() {
2335            assert!(
2336                !path.contains("shell"),
2337                "no request may reach a shell route: {path}"
2338            );
2339        }
2340
2341        // The launch carried exactly the connector set the caller asked for —
2342        // `ALL_INGRESS` from the default plus the egress `with_egress` added — and this
2343        // client injected nothing of its own. Set-equality cuts both ways: a connector
2344        // the caller never named (the shell, or anything else) fails it, and so does a
2345        // dropped one.
2346        let launch = fake.first_body("RunMicrovm");
2347        assert_eq!(
2348            launch["ingressNetworkConnectors"],
2349            serde_json::json!([
2350                "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:ALL_INGRESS"
2351            ]),
2352            "ingress is exactly what the caller asked for"
2353        );
2354        assert_eq!(
2355            launch["egressNetworkConnectors"],
2356            serde_json::json!([
2357                "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:INTERNET_EGRESS"
2358            ]),
2359            "egress is exactly what the caller asked for"
2360        );
2361    }
2362
2363    /// The measured pair `[HTTP_INGRESS, SHELL_INGRESS]` reaches the wire intact — the
2364    /// combination `docs/PLATFORM.md` (2026-08-15) records as launching and minting a
2365    /// shell token successfully. Both are ingress connectors, so both must land in
2366    /// `ingressNetworkConnectors`; a split that only recognized `ALL_INGRESS` as ingress
2367    /// would silently drop them, and this is the test that catches that.
2368    #[tokio::test]
2369    async fn http_plus_shell_ingress_is_the_pair_that_reaches_the_wire() {
2370        let (plane, fake, _) = planted();
2371        fake.answer(
2372            "RunMicrovm",
2373            Answer::ok(fake::microvm_response("PENDING", None)),
2374        );
2375
2376        let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
2377        let mut request = RunMicrovmRequest::new("arn:image", payload);
2378        request.connectors = vec![
2379            super::super::ConnectorIntent::HttpIngress,
2380            super::super::ConnectorIntent::ShellIngress,
2381        ];
2382        plane.run_microvm(request).await.expect("launches");
2383
2384        let launch = fake.first_body("RunMicrovm");
2385        assert_eq!(
2386            launch["ingressNetworkConnectors"],
2387            serde_json::json!([
2388                "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:HTTP_INGRESS",
2389                "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:SHELL_INGRESS"
2390            ]),
2391            "both fine-grained ingress connectors reach the wire, in the order asked"
2392        );
2393        assert_eq!(
2394            launch.get("egressNetworkConnectors"),
2395            None,
2396            "no egress was asked for, so the member is absent rather than empty"
2397        );
2398    }
2399
2400    /// `ALL_INGRESS` combined with the shell connector is refused **before the wire**.
2401    /// The platform accepts the invalid set at launch — the VM reaches RUNNING and bills
2402    /// — and rejects it only when a shell token is minted (`docs/PLATFORM.md`,
2403    /// 2026-08-15). A local refusal is the only one that costs nothing.
2404    ///
2405    /// **Falsification** — run 2026-08-31: with the combination guard in
2406    /// [`ControlPlane::run_microvm`] disabled, this failed on the error assertion.
2407    /// Restored.
2408    #[tokio::test]
2409    async fn all_ingress_combined_with_shell_ingress_is_refused_before_the_wire() {
2410        let (plane, fake, _) = planted();
2411
2412        let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
2413        let mut request = RunMicrovmRequest::new("arn:image", payload);
2414        request
2415            .connectors
2416            .push(super::super::ConnectorIntent::ShellIngress);
2417        let error = plane
2418            .run_microvm(request)
2419            .await
2420            .expect_err("the invalid combination is refused locally");
2421
2422        assert!(
2423            error
2424                .to_string()
2425                .contains("HTTP_INGRESS and/or SHELL_INGRESS"),
2426            "the refusal names the pair that works: {error}"
2427        );
2428        assert_eq!(
2429            fake.call_count("RunMicrovm"),
2430            0,
2431            "nothing launched, so nothing billed"
2432        );
2433    }
2434
2435    /// **W1, measured constraint 1, second half.** `SHELL_INGRESS` without `HTTP_INGRESS`
2436    /// is the same launches-bills-fails-late shape as `ALL_INGRESS` plus a finer connector:
2437    /// the measured pair that launches and mints a shell token is `[HTTP_INGRESS,
2438    /// SHELL_INGRESS]` (`docs/PLATFORM.md`), no measurement says a shell-only ingress set
2439    /// even mints, and the platform accepts an invalid set at launch — the VM reaches
2440    /// RUNNING and bills before any refusal appears. Refused here instead, before the wire.
2441    ///
2442    /// **Falsification** — run 2026-08-31. This test was written before the guard clause
2443    /// existed and failed on both assertions (`run_microvm` reached the wire and launched);
2444    /// adding the shell-without-HTTP refusal in `run_microvm` turned it green.
2445    #[tokio::test]
2446    async fn shell_ingress_without_http_ingress_is_refused_before_the_wire() {
2447        let (plane, fake, _) = planted();
2448
2449        let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
2450        let mut request = RunMicrovmRequest::new("arn:image", payload);
2451        request.connectors = vec![
2452            super::super::ConnectorIntent::ShellIngress,
2453            super::super::ConnectorIntent::Egress,
2454        ];
2455        let error = plane
2456            .run_microvm(request)
2457            .await
2458            .expect_err("shell ingress without HTTP ingress is refused locally");
2459
2460        assert!(
2461            error.to_string().contains("HTTP_INGRESS"),
2462            "the refusal names the missing connector: {error}"
2463        );
2464        assert_eq!(
2465            fake.call_count("RunMicrovm"),
2466            0,
2467            "nothing launched, so nothing billed"
2468        );
2469    }
2470
2471    /// A page the service throttles is retried, not the end of the listing. The service
2472    /// sent that throttle as HTTP 400 named only by the `x-amzn-ErrorType` header
2473    /// (docs/PLATFORM.md, 2026-10-02), and a fleet of 140 VMs, fourteen pages read back to
2474    /// back, met it on most listings, each of which then failed as a validation refusal.
2475    ///
2476    /// **Falsification** — run 2026-10-02. Guard the classifier's header arm with `false` and
2477    /// the listing stops at the throttled page with the 400 as its error, so `expect` panics
2478    /// (`guards:fire -- --only control-listing-stops-at-a-throttled-page`). Restored.
2479    #[tokio::test]
2480    async fn the_fleet_listing_retries_a_page_the_service_throttled_as_400() {
2481        let (plane, fake, _) = planted();
2482        fake.answer(
2483            "ListMicrovms",
2484            Answer::ok(fake::list_microvms_page(
2485                &["mvm-page1-a"],
2486                Some("fleet-page-2"),
2487            )),
2488        )
2489        .answer("ListMicrovms", Answer::throttled_as_400())
2490        .answer(
2491            "ListMicrovms",
2492            Answer::ok(fake::list_microvms_page(&["mvm-page2-a"], None)),
2493        );
2494
2495        let fleet = plane
2496            .list_microvms()
2497            .await
2498            .expect("lists past the throttle");
2499        let ids: Vec<&str> = fleet.iter().map(|vm| vm.microvm_id.as_str()).collect();
2500        assert_eq!(ids, ["mvm-page1-a", "mvm-page2-a"]);
2501        assert_eq!(
2502            fake.call_count("ListMicrovms"),
2503            3,
2504            "page two was asked for again after the throttle"
2505        );
2506        let paths = fake.paths();
2507        assert_eq!(paths[1], paths[2], "the retry is the identical request");
2508    }
2509
2510    /// **Issue #23.** The fleet listing follows `nextToken`, so a fleet larger than one page
2511    /// is not silently truncated.
2512    ///
2513    /// One page is a **confidently wrong** answer rather than a missing one: the caller gets a
2514    /// list that looks complete, and a VM absent from it is a VM nothing here will terminate.
2515    ///
2516    /// The assertion is the fleet **length** and the presence of the page-two ids, which is
2517    /// what a test against the single-page code cannot satisfy.
2518    ///
2519    /// **Falsification** — run 2026-08-15. Replace the `Some(token)` arm with
2520    /// `Some(_) => return Ok(items)` and this fails with 2 VMs instead of 3, and both
2521    /// page-two `contains` assertions go red. Restored.
2522    #[tokio::test]
2523    async fn the_fleet_listing_follows_next_token_to_the_vms_on_page_two() {
2524        let (plane, fake, _) = planted();
2525        fake.answer(
2526            "ListMicrovms",
2527            Answer::ok(fake::list_microvms_page(
2528                &["mvm-page1-a", "mvm-page1-b"],
2529                Some("fleet-page-2"),
2530            )),
2531        )
2532        .answer(
2533            "ListMicrovms",
2534            Answer::ok(fake::list_microvms_page(&["mvm-page2-a"], None)),
2535        );
2536
2537        let fleet = plane.list_microvms().await.expect("lists");
2538        assert_eq!(
2539            fleet.len(),
2540            3,
2541            "a fleet spanning two pages is three VMs, not two"
2542        );
2543
2544        let ids: Vec<&str> = fleet.iter().map(|vm| vm.microvm_id.as_str()).collect();
2545        assert!(ids.contains(&"mvm-page2-a"), "page two is missing: {ids:?}");
2546        assert!(ids.contains(&"mvm-page1-a"), "{ids:?}");
2547        assert_eq!(fake.call_count("ListMicrovms"), 2, "both pages were read");
2548
2549        let paths = fake.paths();
2550        assert!(
2551            paths[1].contains("nextToken=fleet-page-2"),
2552            "the second request must carry the first page's token: {}",
2553            paths[1]
2554        );
2555        assert!(
2556            !paths[0].contains('?'),
2557            "the first request carries no cursor: {}",
2558            paths[0]
2559        );
2560    }
2561
2562    /// **A VM the service lists on two pages is listed once (#280).**
2563    ///
2564    /// Measured 2026-10-01 (docs/PLATFORM.md, "A paginated `ListMicrovms` can return one VM on
2565    /// two of its pages"): while VMs change state, a `nextToken` walk can meet one VM twice.
2566    /// A listing that concatenated the pages counted it twice, so `ls --remote` listed it
2567    /// twice and a teardown would terminate it twice. The later page's entry is the newer
2568    /// reading, so it is the one kept, at the place the VM was first listed.
2569    #[tokio::test]
2570    async fn a_vm_the_fleet_listing_meets_on_two_pages_is_listed_once() {
2571        let (plane, fake, _) = planted();
2572        fake.answer(
2573            "ListMicrovms",
2574            Answer::ok(fake::list_microvms_page(
2575                &["mvm-a", "mvm-moved"],
2576                Some("fleet-page-2"),
2577            )),
2578        )
2579        .answer(
2580            "ListMicrovms",
2581            Answer::ok(
2582                fake::list_microvms_page(&["mvm-moved", "mvm-b"], None).replacen(
2583                    "RUNNING",
2584                    "SUSPENDED",
2585                    1,
2586                ),
2587            ),
2588        );
2589
2590        let fleet = plane.list_microvms().await.expect("lists");
2591        let ids: Vec<&str> = fleet.iter().map(|vm| vm.microvm_id.as_str()).collect();
2592        assert_eq!(
2593            ids,
2594            ["mvm-a", "mvm-moved", "mvm-b"],
2595            "each VM once, in the order first listed"
2596        );
2597        assert_eq!(
2598            fleet[1].state, "SUSPENDED",
2599            "the page read later is the newer reading of the VM"
2600        );
2601        assert_eq!(fleet[0].state, "RUNNING");
2602        assert_eq!(fake.call_count("ListMicrovms"), 2, "both pages were read");
2603    }
2604
2605    /// **Issue #25.** The `idlePolicy` the service reports is carried rather than dropped.
2606    ///
2607    /// The comment on [`ops::IdlePolicy`] used to claim `suspendedDurationSeconds` "exists
2608    /// only in the request". A live `GetMicrovm` on a RUNNING VM disagrees, which is what the
2609    /// fake's body now reflects — so this test is over a response shape that was measured
2610    /// rather than assumed.
2611    ///
2612    /// **Falsification** — drop `idle_policy` from the `From<MicrovmResponseWire>` impl and
2613    /// this does not compile; set it to `None` there and every field assertion goes red.
2614    #[tokio::test]
2615    async fn the_reported_idle_policy_is_carried_rather_than_dropped() {
2616        let (plane, fake, _) = planted();
2617        fake.answer(
2618            "GetMicrovm",
2619            Answer::ok(fake::microvm_response("RUNNING", None)),
2620        );
2621
2622        let vm = plane.get_microvm("mvm-1").await.expect("reads");
2623        let policy = vm
2624            .idle_policy
2625            .expect("GetMicrovm returns an idlePolicy, measured 2026-08-15");
2626        assert_eq!(policy.max_idle_duration_seconds, 1800);
2627        assert_eq!(
2628            policy.suspended_duration_seconds, 600,
2629            "the member the comment claimed was request-only"
2630        );
2631        assert!(!policy.auto_resume_enabled);
2632    }
2633
2634    /// A response with no `idlePolicy` still parses, and the absence is `None` rather than a
2635    /// default. The model does not mark the member required on either response shape, so an
2636    /// invented `0` would be a window nobody configured.
2637    #[tokio::test]
2638    async fn an_absent_idle_policy_is_none_rather_than_a_zero_window() {
2639        let (plane, fake, _) = planted();
2640        fake.answer(
2641            "GetMicrovm",
2642            Answer::ok(
2643                r#"{
2644                    "microvmId": "mvm-1",
2645                    "state": "RUNNING",
2646                    "endpoint": "https://e",
2647                    "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
2648                    "imageVersion": "1"
2649                }"#,
2650            ),
2651        );
2652
2653        let vm = plane.get_microvm("mvm-1").await.expect("parses");
2654        assert_eq!(vm.idle_policy, None);
2655    }
2656
2657    /// A transport failure is retried and then succeeds, so a connection reset on the way to
2658    /// a launch is not a failed launch.
2659    #[tokio::test]
2660    async fn a_transport_failure_is_retried_rather_than_reported() {
2661        let (plane, fake, _) = planted();
2662        fake.fail_transport("GetMicrovm", 2).answer(
2663            "GetMicrovm",
2664            Answer::ok(fake::microvm_response("RUNNING", None)),
2665        );
2666
2667        let vm = plane
2668            .get_microvm("mvm-1")
2669            .await
2670            .expect("the retry succeeds");
2671        assert_eq!(vm.state, "RUNNING");
2672        assert_eq!(
2673            fake.call_count("GetMicrovm"),
2674            3,
2675            "two failures then a success"
2676        );
2677    }
2678
2679    // ── issue #24's guards on the launch and lifecycle paths ─────────────────
2680
2681    /// **`max_idle_sec: 59` no longer reaches the wire.**
2682    ///
2683    /// The measurement issue #24 made, turned into a test. The exemption it replaces was not a
2684    /// wrong number — it was a correct fact about botocore's `VALIDATED_METADATA_ATTRS` applied to
2685    /// a client that does not use botocore, so `IdlePolicy.maxIdleDurationSeconds` was the one
2686    /// constraint in the model with a *reason* for having no guard rather than an oversight.
2687    ///
2688    /// The fake has `RunMicrovm` answered, so a missing guard shows as a launch that **succeeded**
2689    /// with 59 on the wire rather than as some other failure. That is what makes the zero-call
2690    /// assertion the load-bearing one.
2691    ///
2692    /// **Guard proof** — run 2026-08-16. Delete `require_idle_duration` from `run_microvm` and
2693    /// this fails on `expect_err`, with the fake recording `RunMicrovm` once and
2694    /// `idlePolicy.maxIdleDurationSeconds: 59` in its body.
2695    #[tokio::test]
2696    async fn an_under_minimum_idle_duration_reaches_no_control_plane_call() {
2697        for under in [0, 1, 59] {
2698            let (plane, fake, _) = planted();
2699            fake.answer(
2700                "RunMicrovm",
2701                Answer::ok(fake::microvm_response("PENDING", None)),
2702            );
2703
2704            let payload = RunHookPayload::for_agent_token("token").expect("fits");
2705            let mut request = RunMicrovmRequest::new("arn:image", payload);
2706            request.max_idle_sec = under;
2707
2708            let error = plane
2709                .run_microvm(request)
2710                .await
2711                .expect_err("the model's min is 60");
2712            assert_eq!(error.kind(), ErrorKind::InvalidArg);
2713            let message = error.to_string();
2714            assert!(message.contains("maxIdleDurationSeconds"), "{message}");
2715            assert!(message.contains(&under.to_string()), "{message}");
2716            assert_eq!(
2717                fake.calls().len(),
2718                0,
2719                "{under} was refused locally; before this guard it was serialized and sent"
2720            );
2721        }
2722
2723        // 60 exactly does launch, so the boundary is inclusive and the guard is not refusing
2724        // every idle window.
2725        let (plane, fake, _) = planted();
2726        fake.answer(
2727            "RunMicrovm",
2728            Answer::ok(fake::microvm_response("PENDING", None)),
2729        );
2730        let payload = RunHookPayload::for_agent_token("token").expect("fits");
2731        let mut request = RunMicrovmRequest::new("arn:image", payload);
2732        request.max_idle_sec = 60;
2733        plane.run_microvm(request).await.expect("60 is the minimum");
2734        assert_eq!(
2735            fake.first_body("RunMicrovm")["idlePolicy"]["maxIdleDurationSeconds"],
2736            60
2737        );
2738    }
2739
2740    /// **The launch's `executionRoleArn` and `imageIdentifier`, refused before the call.**
2741    ///
2742    /// `executionRoleArn` is filled from an infra config on every real launch — the CLI reads it
2743    /// out of `MICROVM_EXECUTION_ROLE_ARN` — so a malformed one is a `ValidationException` about a
2744    /// member the caller never typed. Naming it locally is the whole difference.
2745    ///
2746    /// **Guard proof** — run 2026-08-16. Delete `require_valid_role_arn("executionRoleArn", …)`
2747    /// and the role rows launch successfully with the bad ARN on the wire; delete
2748    /// `require_valid_identifier` and the identifier rows do.
2749    #[tokio::test]
2750    async fn a_malformed_launch_role_or_identifier_reaches_no_control_plane_call() {
2751        /// What to break, aliased for the reason clippy's `type_complexity` gives — and the same
2752        /// alias `image.rs`'s create-path table uses, so the two read alike.
2753        type Mutate = fn(&mut RunMicrovmRequest);
2754        let rows: [(&str, Mutate, &str); 6] = [
2755            (
2756                "an execution role that is a bare name",
2757                |request| {
2758                    request.execution_role_arn = Some("execution-role".to_string());
2759                },
2760                "role *name*",
2761            ),
2762            (
2763                "an execution role with eleven account digits",
2764                |request| {
2765                    request.execution_role_arn =
2766                        Some("arn:aws:iam::12345678901:role/exec".to_string());
2767                },
2768                "exactly twelve digits",
2769            ),
2770            (
2771                "an execution role that is a function ARN",
2772                |request| {
2773                    request.execution_role_arn =
2774                        Some("arn:aws:lambda:us-east-1:123456789012:function:h".to_string());
2775                },
2776                "RoleArn pattern",
2777            ),
2778            (
2779                "an empty image identifier",
2780                |request| {
2781                    request.image_identifier = String::new();
2782                },
2783                "collapses the path",
2784            ),
2785            (
2786                "a 257-character image identifier",
2787                |request| {
2788                    request.image_identifier = "a".repeat(257);
2789                },
2790                "MicrovmImageArn permits 2048",
2791            ),
2792            (
2793                "an image version with a newline",
2794                |request| {
2795                    request.image_version = Some("2.0\n".to_string());
2796                },
2797                "contains whitespace",
2798            ),
2799        ];
2800
2801        for (label, mutate, expected) in rows {
2802            let (plane, fake, _) = planted();
2803            fake.answer(
2804                "RunMicrovm",
2805                Answer::ok(fake::microvm_response("PENDING", None)),
2806            );
2807
2808            let payload = RunHookPayload::for_agent_token("token").expect("fits");
2809            let mut request = RunMicrovmRequest::new("arn:image", payload);
2810            mutate(&mut request);
2811
2812            let error = plane
2813                .run_microvm(request)
2814                .await
2815                .expect_err(&format!("{label} must be refused"));
2816            assert_eq!(error.kind(), ErrorKind::InvalidArg, "{label}");
2817            assert!(
2818                error.to_string().contains(expected),
2819                "{label}: wanted {expected:?}, got {error}"
2820            );
2821            assert_eq!(fake.calls().len(), 0, "{label}");
2822        }
2823
2824        // The control case: a launch carrying a real execution role, a pinned version, and the
2825        // account's actual ARN shape goes through.
2826        let (plane, fake, _) = planted();
2827        fake.answer(
2828            "RunMicrovm",
2829            Answer::ok(fake::microvm_response("PENDING", None)),
2830        );
2831        let payload = RunHookPayload::for_agent_token("token").expect("fits");
2832        let mut request = RunMicrovmRequest::new(
2833            "arn:aws:lambda:us-east-1:392583147479:microvm-image:agentd-conformance",
2834            payload,
2835        );
2836        request.execution_role_arn =
2837            Some("arn:aws:iam::392583147479:role/bonk-sandbox-microvm-execution".to_string());
2838        request.image_version = Some("1.0".to_string());
2839        plane
2840            .run_microvm(request)
2841            .await
2842            .expect("a realistic pinned launch with a real execution role");
2843        assert_eq!(fake.call_count("RunMicrovm"), 1);
2844    }
2845
2846    /// **The `microvmIdentifier` guard on every lifecycle operation, proved by the call count.**
2847    ///
2848    /// `TerminateMicrovm` is the one worth reading twice. Its path is
2849    /// `DELETE /microvms/{microvmIdentifier}`, so an empty identifier is a `DELETE` addressed at
2850    /// the fleet collection. The service has no operation there and refuses it, but "send a delete
2851    /// at the collection and trust the service" is not a thing to leave to the service.
2852    ///
2853    /// **Guard proof** — run 2026-08-16. Delete `require_valid_identifier` from any one of the
2854    /// five and its assertion goes red with `calls: 1`.
2855    #[tokio::test]
2856    async fn no_lifecycle_operation_sends_an_empty_or_over_long_microvm_identifier() {
2857        for bad in ["", &"a".repeat(257)] {
2858            let (plane, fake, _) = planted();
2859            // Every operation answered, so a missing guard is a *successful* call rather than a
2860            // different failure.
2861            fake.answer(
2862                "GetMicrovm",
2863                Answer::ok(fake::microvm_response("RUNNING", None)),
2864            );
2865            fake.answer("SuspendMicrovm", Answer::ok("{}"));
2866            fake.answer("ResumeMicrovm", Answer::ok("{}"));
2867            fake.answer("TerminateMicrovm", Answer::ok("{}"));
2868            fake.answer(
2869                "CreateMicrovmAuthToken",
2870                Answer::ok(fake::auth_token_response("t")),
2871            );
2872
2873            plane.get_microvm(bad).await.expect_err("GetMicrovm");
2874            plane.suspend(bad).await.expect_err("SuspendMicrovm");
2875            plane.resume(bad).await.expect_err("ResumeMicrovm");
2876            plane.terminate(bad).await.expect_err("TerminateMicrovm");
2877            plane
2878                .mint_auth_token(bad)
2879                .await
2880                .expect_err("CreateMicrovmAuthToken");
2881            plane
2882                .wait_for_running(bad, WaitOpts::for_launch())
2883                .await
2884                .expect_err("GetMicrovm, in a loop");
2885
2886            assert_eq!(
2887                fake.calls().len(),
2888                0,
2889                "six operations refused {bad:?} locally. An empty identifier on the terminate \
2890                 path is a DELETE addressed at the collection."
2891            );
2892        }
2893    }
2894
2895    /// **Issue #24's `PortNumber` half: a token is never minted for port 0.**
2896    ///
2897    /// The measurement that makes this worth a guard rather than a comment is in
2898    /// [`ops::PortSpecification`]: a token whose `allowedPorts` does not cover the port a request
2899    /// names is refused with **403 `Access to port denied`**, and on the WebSocket path it is close
2900    /// code 1006 with no reason. So a token minted for port 0 authorizes nothing and fails in a
2901    /// way that reads like a bad credential rather than like an empty scope.
2902    ///
2903    /// Both ends of a range are checked as well as the single-port form, because `PortRange`'s two
2904    /// members name the same shape and a range starting at 0 is the same defect.
2905    ///
2906    /// **Guard proof** — run 2026-08-16. Delete the `for spec in ports` loop from
2907    /// `mint_auth_token_for` and all three rows mint successfully with `{"port": 0}` or
2908    /// `{"range": {"startPort": 0, …}}` on the wire.
2909    #[tokio::test]
2910    async fn no_proxy_token_is_minted_for_port_zero() {
2911        let rows: [(&str, ops::PortSpecification, &str); 3] = [
2912            (
2913                "a single port of 0",
2914                ops::PortSpecification::port(0),
2915                "allowedPorts[].port",
2916            ),
2917            (
2918                "a range starting at 0",
2919                ops::PortSpecification::range(0, 9000),
2920                "allowedPorts[].range.startPort",
2921            ),
2922            (
2923                "a range ending at 0",
2924                ops::PortSpecification::range(0, 0),
2925                "allowedPorts[].range.startPort",
2926            ),
2927        ];
2928
2929        for (label, spec, member) in rows {
2930            let (plane, fake, _) = planted();
2931            fake.answer(
2932                "CreateMicrovmAuthToken",
2933                Answer::ok(fake::auth_token_response("t")),
2934            );
2935
2936            let error = plane
2937                .mint_auth_token_for("mvm-1", &[spec])
2938                .await
2939                .expect_err(&format!("{label} must be refused"));
2940            assert_eq!(error.kind(), ErrorKind::InvalidArg, "{label}");
2941            let message = error.to_string();
2942            assert!(message.contains(member), "{label}: {message}");
2943            assert!(
2944                message.contains("authorizes nothing"),
2945                "{label}: the 403 consequence has to be in the message: {message}"
2946            );
2947            assert_eq!(fake.calls().len(), 0, "{label}");
2948        }
2949
2950        // A zero port among legal ones is still refused: the check is per-entry, not on the
2951        // first. A token minted with `[{port: 9000}, {port: 0}]` would look scoped correctly and
2952        // carry a member the service refuses the whole request over.
2953        let (plane, fake, _) = planted();
2954        fake.answer(
2955            "CreateMicrovmAuthToken",
2956            Answer::ok(fake::auth_token_response("t")),
2957        );
2958        plane
2959            .mint_auth_token_for(
2960                "mvm-1",
2961                &[
2962                    ops::PortSpecification::port(9000),
2963                    ops::PortSpecification::port(0),
2964                ],
2965            )
2966            .await
2967            .expect_err("every entry is checked, not the first");
2968        assert_eq!(fake.calls().len(), 0);
2969
2970        // And the three legal forms mint, including `allPorts` — which names no number, so there
2971        // is nothing to bound and the guard must not refuse it.
2972        for legal in [
2973            ops::PortSpecification::port(1),
2974            ops::PortSpecification::port(65_535),
2975            ops::PortSpecification::range(1, 65_535),
2976            ops::PortSpecification::all(),
2977        ] {
2978            let (plane, fake, _) = planted();
2979            fake.answer(
2980                "CreateMicrovmAuthToken",
2981                Answer::ok(fake::auth_token_response("t")),
2982            );
2983            plane
2984                .mint_auth_token_for("mvm-1", &[legal])
2985                .await
2986                .expect("a legal port specification mints");
2987            assert_eq!(fake.call_count("CreateMicrovmAuthToken"), 1);
2988        }
2989    }
2990
2991    /// **#195.** A launch whose client token adopted a VM that idle-suspended since the
2992    /// first attempt resumes it and returns RUNNING, rather than reporting LaunchDied.
2993    ///
2994    /// **Falsification** — route `wait_for_launch` through `TERMINAL_STATES` (the fresh
2995    /// launch's fail set) and this reports LaunchDied on the first SUSPENDED poll.
2996    #[tokio::test]
2997    async fn an_adopted_launch_that_finds_its_vm_suspended_resumes_it() {
2998        for first in ["SUSPENDING", "SUSPENDED"] {
2999            let (plane, fake, _) = planted();
3000            fake.answer(
3001                "GetMicrovm",
3002                Answer::ok(fake::microvm_response(first, None)),
3003            )
3004            .answer(
3005                "GetMicrovm",
3006                Answer::ok(fake::microvm_response("SUSPENDED", None)),
3007            )
3008            .answer(
3009                "GetMicrovm",
3010                Answer::ok(fake::microvm_response("SUSPENDED", None)),
3011            )
3012            .answer(
3013                "GetMicrovm",
3014                Answer::ok(fake::microvm_response("RUNNING", None)),
3015            )
3016            .answer("ResumeMicrovm", Answer::ok(fake::empty_response()));
3017            let running = plane
3018                .wait_for_launch("mvm-abc123", WaitOpts::for_launch())
3019                .await
3020                .unwrap_or_else(|error| panic!("{first}: {error}"));
3021            assert_eq!(running.state, "RUNNING", "{first}");
3022            assert_eq!(
3023                fake.call_count("ResumeMicrovm"),
3024                1,
3025                "{first}: one resume, even though GetMicrovm still read SUSPENDED after it"
3026            );
3027        }
3028    }
3029
3030    /// The adopted-launch wait still fails fast, with `stateReason`, on a dead state.
3031    #[tokio::test]
3032    async fn an_adopted_launch_still_fails_fast_on_a_dead_state() {
3033        for state in crate::constants::DEAD_STATES {
3034            let (plane, fake, _) = planted();
3035            fake.answer(
3036                "GetMicrovm",
3037                Answer::ok(fake::microvm_response(state, Some("hook timeout"))),
3038            );
3039            let error = plane
3040                .wait_for_launch("mvm-abc123", WaitOpts::for_launch())
3041                .await
3042                .expect_err(&format!("{state} is a death"));
3043            assert_eq!(error.kind(), ErrorKind::LaunchDied, "{state}");
3044            assert!(
3045                error.to_string().contains("hook timeout"),
3046                "{state}: {error}"
3047            );
3048            assert_eq!(fake.call_count("GetMicrovm"), 1, "{state}");
3049            assert_eq!(fake.call_count("ResumeMicrovm"), 0, "{state}");
3050        }
3051    }
3052
3053    /// `startedAt`, `terminatedAt`, and `maximumDurationInSeconds` reach [`Microvm`].
3054    #[tokio::test]
3055    async fn get_microvm_carries_the_lifetime_members() {
3056        let (plane, fake, _) = planted();
3057        fake.answer(
3058            "GetMicrovm",
3059            Answer::ok(fake::microvm_response("RUNNING", None)),
3060        );
3061        let vm = plane.get_microvm("mvm-abc123").await.expect("parses");
3062        assert_eq!(vm.maximum_duration_seconds, Some(3600));
3063        assert_eq!(
3064            vm.started_at,
3065            Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_754_524_800))
3066        );
3067        assert_eq!(vm.terminated_at, None);
3068    }
3069
3070    /// A timestamp past what a `Duration` holds reads as absent, as a negative one does, and
3071    /// the rest of the reply still parses. The conversion ran before the checked add, so such
3072    /// a reply panicked the parser instead (#335). The negative figure is last, so the fault
3073    /// below panics on an out-of-range one first.
3074    ///
3075    /// **Falsification**: `verify/guards/faults/seconds-flags.toml` entry
3076    /// `app-epoch-past-a-duration-panics` restores `Duration::from_secs_f64` in `epoch`, and
3077    /// the `1e300` reply panics.
3078    #[tokio::test]
3079    async fn an_out_of_range_timestamp_reads_as_absent() {
3080        for seconds in ["2e19", "1e300", "1.7976931348623157e308", "-1"] {
3081            let (plane, fake, _) = planted();
3082            let body = fake::microvm_response("TERMINATED", None).replace(
3083                r#""startedAt": 1754524800"#,
3084                &format!(r#""startedAt": {seconds}, "terminatedAt": {seconds}"#),
3085            );
3086            fake.answer("GetMicrovm", Answer::ok(body));
3087            let vm = plane
3088                .get_microvm("mvm-abc123")
3089                .await
3090                .unwrap_or_else(|error| panic!("{seconds}: {error}"));
3091            assert_eq!(vm.state, "TERMINATED", "{seconds}");
3092            assert_eq!(vm.maximum_duration_seconds, Some(3600), "{seconds}");
3093            assert_eq!(vm.started_at, None, "{seconds}");
3094            assert_eq!(vm.terminated_at, None, "{seconds}");
3095        }
3096    }
3097
3098    /// The list filters reach the query string in SigV4's sorted order, ARN colons escaped.
3099    #[tokio::test]
3100    async fn list_filters_reach_the_query_string() {
3101        let (plane, fake, _) = planted();
3102        fake.answer(
3103            "ListMicrovms",
3104            Answer::ok(fake::list_microvms_page(&["mvm-1"], None)),
3105        );
3106        let filter = MicrovmFilter {
3107            image_identifier: Some(
3108                "arn:aws:lambda:us-east-1:123456789012:microvm-image:img".into(),
3109            ),
3110            image_version: Some("1.0".into()),
3111        };
3112        let items = plane.list_microvms_matching(&filter).await.expect("lists");
3113        assert_eq!(items.len(), 1);
3114        let path = fake.paths().pop().expect("one call");
3115        assert!(
3116            path.ends_with(
3117                "/microvms?imageIdentifier=arn%3Aaws%3Alambda%3Aus-east-1%3A123456789012%3Amicrovm-image%3Aimg&imageVersion=1.0"
3118            ),
3119            "{path}"
3120        );
3121        plane.list_microvms().await.expect("unfiltered");
3122        assert!(fake.paths().pop().expect("second").ends_with("/microvms"));
3123    }
3124
3125    /// Per-VM logging reaches the wire, and a malformed group is refused before any call.
3126    #[tokio::test]
3127    async fn per_vm_logging_is_sent_and_checked_locally() {
3128        let (plane, fake, _) = planted();
3129        fake.answer(
3130            "RunMicrovm",
3131            Answer::ok(fake::microvm_response("PENDING", None)),
3132        );
3133        let payload = || RunHookPayload::for_agent_token("t").expect("fits");
3134        let mut request = RunMicrovmRequest::new("arn:image", payload());
3135        request.logging = Some(ops::Logging::cloud_watch("/team/agents", None));
3136        plane.run_microvm(request).await.expect("launches");
3137        assert_eq!(
3138            fake.first_body("RunMicrovm")["logging"],
3139            serde_json::json!({"cloudWatch": {"logGroup": "/team/agents"}})
3140        );
3141
3142        let mut disabled = RunMicrovmRequest::new("arn:image", payload());
3143        disabled.logging = Some(ops::Logging::disabled());
3144        plane.run_microvm(disabled).await.expect("launches");
3145        assert_eq!(
3146            fake.body_of("RunMicrovm", 1)["logging"],
3147            serde_json::json!({"disabled": {}})
3148        );
3149
3150        let plain = RunMicrovmRequest::new("arn:image", payload());
3151        plane.run_microvm(plain).await.expect("launches");
3152        assert!(fake.body_of("RunMicrovm", 2).get("logging").is_none());
3153
3154        for bad in [
3155            ops::Logging::cloud_watch("bad group!", None),
3156            ops::Logging::CloudWatch {
3157                cloud_watch: ops::CloudWatchLogging {
3158                    log_group: None,
3159                    log_stream: Some("s".into()),
3160                },
3161            },
3162        ] {
3163            let mut request = RunMicrovmRequest::new("arn:image", payload());
3164            request.logging = Some(bad);
3165            let error = plane.run_microvm(request).await.expect_err("refused");
3166            assert_eq!(error.kind(), ErrorKind::InvalidArg, "{error}");
3167        }
3168        assert_eq!(
3169            fake.call_count("RunMicrovm"),
3170            3,
3171            "the refusals cost no call"
3172        );
3173    }
3174}