Skip to main content

microvms_core/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: there is no shell-auth method here
41//!
42//! [`ControlPlane::mint_auth_token`] calls `CreateMicrovmAuthToken`. There is no sibling for
43//! `CreateMicrovmShellAuthToken`, deliberately — see [`super`].
44
45use super::transport::{Call, paths, send_with_retry};
46use super::{ControlPlane, RunMicrovmRequest, WaitOpts, ops, timed_out, token};
47use crate::error::{Error, ErrorKind};
48
49/// The proxy token's auth header, which is also the key in the `authToken` map.
50pub const PROXY_AUTH_HEADER: &str = "X-aws-proxy-auth";
51
52/// The proxy port header.
53///
54/// Sent on **every** endpoint request alongside the auth header: `X-aws-proxy-auth` without
55/// `X-aws-proxy-port` is rejected in a way that reads like a bad token, which is a long
56/// detour for a missing header.
57pub const PROXY_PORT_HEADER: &str = "X-aws-proxy-port";
58
59/// The service ceiling on a proxy token's life, in minutes.
60pub const MAX_TOKEN_MINUTES: u32 = 60;
61
62/// A `runHookPayload` that is known to fit the service ceiling.
63///
64/// The type is the guard: there is no way to build one over 4096 bytes, so no call site has
65/// to remember to check. See the module docs for the measurement and for why the service
66/// model's own documentation string disagrees with its shape.
67#[derive(Clone, Eq, PartialEq)]
68pub struct RunHookPayload(String);
69
70/// Prints the size and nothing else.
71///
72/// The payload is the platform's only per-VM secret channel — its whole purpose is to carry
73/// the agent token — so a derived `Debug` would print that token into every log line that
74/// formats one, and into every line that formats the [`RunMicrovmRequest`] holding it.
75/// [`RunMicrovmRequest`] keeps its derive: with this impl in place, the derived one prints
76/// `RunHookPayload(<N bytes>)` for that field, so the safety is inherited rather than
77/// restated. The length is kept because it is the number every TRAP-5 diagnosis needs.
78impl std::fmt::Debug for RunHookPayload {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        write!(f, "RunHookPayload(<{} bytes>)", self.0.len())
81    }
82}
83
84impl RunHookPayload {
85    /// The payload carrying an agent token, as the daemon expects to find it.
86    ///
87    /// `{"agent_token": "<token>"}` — and the platform wraps *this whole string* as the
88    /// value of `runHookPayload`, which is the extra parse the daemon does.
89    ///
90    /// Checked even though this function builds the JSON itself, because the token is
91    /// caller-supplied: someone passing a JWT or a signed blob rather than a bearer token is
92    /// exactly who this catches.
93    pub fn for_agent_token(agent_token: &str) -> Result<Self, Error> {
94        Self::for_launch(agent_token, &std::collections::HashMap::new())
95    }
96
97    /// The payload carrying an agent token and a launch environment.
98    ///
99    /// `{"agent_token": "<token>", "env": {...}}`, and the platform wraps that whole
100    /// string as the value of `runHookPayload` — the extra parse the daemon does.
101    /// The daemon applies `env` as the base environment of every subsequent exec,
102    /// under the per-request `env`.
103    ///
104    /// **This is where the 4096-byte refusal fires for a launch env.** The check is
105    /// on the serialized payload rather than on the map, because the ceiling is on
106    /// the string: JSON escaping, the key names, and the punctuation all count, so a
107    /// caller who measured their own values would measure the wrong thing. And it is
108    /// local rather than left to the service for the same reason
109    /// [`super::super::control::image`]'s `require_workdir` and
110    /// `require_matching_from` are local: the service's answer arrives as a
111    /// `ValidationException` on a member the caller did not know they were filling,
112    /// after the call, and botocore does not enforce it client-side at all (measured;
113    /// `docs/PLATFORM.md`). `env` is what makes this reachable in practice — one
114    /// bearer token has always fit with room to spare, and a map of credentials does
115    /// not.
116    ///
117    /// The env is omitted from the JSON entirely when it is empty, so a caller who
118    /// passes no launch env produces byte-for-byte the payload
119    /// [`RunHookPayload::for_agent_token`] always produced. A pinned daemon that has
120    /// never heard of `env` therefore sees no change, and the two constructors do not
121    /// have two different budgets.
122    pub fn for_launch(
123        agent_token: &str,
124        env: &std::collections::HashMap<String, String>,
125    ) -> Result<Self, Error> {
126        Self::for_launch_with_identity(agent_token, env, None)
127    }
128
129    /// [`RunHookPayload::for_launch`], optionally carrying the tunnel identity material.
130    ///
131    /// The two identity fields are the VM's own seed and the launching host's public key,
132    /// under the keys `protocol::identity` declares — the daemon pins one and derives from
133    /// the other, which is what makes `microvm tunnel --verify-identity` able to prove the
134    /// far end without trusting the endpoint proxy. `None` produces byte-for-byte the
135    /// payload the other constructors always built, so a caller who never asks for identity
136    /// cannot be affected by the field existing (the same compatibility rule `env` follows).
137    ///
138    /// The material costs [`protocol::identity::IDENTITY_PAYLOAD_BYTES`] of the shared
139    /// 4096-byte budget — two 44-character base64 values plus JSON framing — and the ceiling
140    /// check below covers it like everything else in the payload.
141    pub fn for_launch_with_identity(
142        agent_token: &str,
143        env: &std::collections::HashMap<String, String>,
144        identity: Option<&crate::identity::LaunchIdentity>,
145    ) -> Result<Self, Error> {
146        let mut payload = if env.is_empty() {
147            serde_json::json!({ "agent_token": agent_token })
148        } else {
149            serde_json::json!({ "agent_token": agent_token, "env": env })
150        };
151        if let Some(identity) = identity {
152            let object = payload
153                .as_object_mut()
154                .expect("the payload is built as an object two lines up");
155            object.insert(
156                protocol::identity::SEED_KEY.to_string(),
157                serde_json::Value::String(identity.seed_field()),
158            );
159            object.insert(
160                protocol::identity::HOST_PUBLIC_KEY_KEY.to_string(),
161                serde_json::Value::String(identity.host_public_field()),
162            );
163        }
164        let text = serde_json::to_string(&payload).map_err(|error| {
165            Error::new(
166                ErrorKind::Unexpected,
167                format!("could not serialize the run-hook payload: {error}"),
168            )
169        })?;
170        // The length is read before the value moves into `new`, so the refusal below can
171        // account for it without rebuilding the payload.
172        let total = text.len();
173        Self::new(text).map_err(|error| {
174            if env.is_empty() {
175                return error;
176            }
177            // The generic message names the byte count and the ceiling, which is the
178            // whole diagnosis for a token. For a launch env it is half of one: the
179            // caller wants to know how much of the budget their env is, because the
180            // fix is to move a value out of it rather than to shorten the token.
181            Error::invalid_arg(format!(
182                "{error} The launch env contributed {} of those bytes across {} variable(s); \
183                 the payload's other {} are the token and the JSON framing. A launch env is \
184                 for small values a workload needs at startup — move credential-scale \
185                 material to PUT /v1/fs/file after bootstrap, or to a role the workload \
186                 assumes.",
187                env_contribution(agent_token, env),
188                env.len(),
189                total.saturating_sub(env_contribution(agent_token, env)),
190            ))
191        })
192    }
193
194    /// An arbitrary payload, if it fits.
195    ///
196    /// Public because the run-hook body is the caller's channel and this crate does not own
197    /// what goes in it; the ceiling is the only thing it enforces.
198    pub fn new(payload: impl Into<String>) -> Result<Self, Error> {
199        let payload = payload.into();
200        // Bytes, not characters: the ceiling is on the serialized string, so a payload
201        // measured by character count passes while the same value with one multi-byte
202        // character in it does not.
203        let size = payload.len();
204        if size <= crate::constants::MAX_RUN_HOOK_PAYLOAD_BYTES {
205            return Ok(Self(payload));
206        }
207        Err(Error::invalid_arg(format!(
208            "runHookPayload is {size} bytes, over the ceiling of {} (service model {}, measured \
209             inclusive 2026-08-07: 4096 passes and 4097 fails). This is the only per-VM secret \
210             channel the platform offers — one bearer token fits, a cloud credential set does \
211             not. Note that docs/STRATEGY.md, docs/TRUST.md, and the service model's own \
212             documentation string for this member all claim 16 KB, which is wrong by 4x in the \
213             dangerous direction; the shape RunMicrovmRequestRunHookPayloadString is the \
214             authority and it states max 4096.",
215            crate::constants::MAX_RUN_HOOK_PAYLOAD_BYTES,
216            crate::constants::MODEL_API_VERSION,
217        )))
218    }
219
220    /// The payload's bytes as the wire will carry them.
221    pub fn as_str(&self) -> &str {
222        &self.0
223    }
224
225    /// The size in bytes, which is what the ceiling measures.
226    pub fn len(&self) -> usize {
227        self.0.len()
228    }
229
230    /// Whether the payload is empty. The model's minimum is 0, so an empty payload is legal.
231    pub fn is_empty(&self) -> bool {
232        self.0.is_empty()
233    }
234}
235
236/// How many of a launch payload's bytes the env accounts for.
237///
238/// Measured by difference against the token-only payload rather than by summing key and
239/// value lengths, because the ceiling is on the *serialized* string: the `"env":{}`
240/// wrapper, the quotes, the commas, and every backslash JSON escaping adds all count
241/// against the budget, and a figure that summed the raw values would understate exactly
242/// the payloads that are near the limit.
243///
244/// Only ever called on the refusal path, where the payload has already been built once.
245fn env_contribution(agent_token: &str, env: &std::collections::HashMap<String, String>) -> usize {
246    let with = serde_json::json!({ "agent_token": agent_token, "env": env }).to_string();
247    let without = serde_json::json!({ "agent_token": agent_token }).to_string();
248    with.len().saturating_sub(without.len())
249}
250
251/// A launched MicroVM, as the service last described it.
252#[derive(Clone, Debug, Eq, PartialEq)]
253pub struct Microvm {
254    pub id: String,
255    pub state: String,
256    /// The proxy endpoint. Present from the launch response onward.
257    pub endpoint: String,
258    pub image_arn: String,
259    pub image_version: String,
260    /// Why the VM is in this state, when the service said. The absence is information —
261    /// TRAP-8's message distinguishes "no stateReason" from an empty one.
262    pub state_reason: Option<String>,
263    /// The idle policy the service reports the VM is running under.
264    ///
265    /// Carried rather than dropped because it is the platform's own account of the window
266    /// that will suspend and then terminate this VM, and it was measured coming back on a
267    /// real `GetMicrovm` — see [`ops::IdlePolicy`], which used to claim it does not. The
268    /// client's request is what *asked* for a window; this is what the service says it got,
269    /// and a caller diagnosing a VM that vanished earlier than expected needs the second.
270    ///
271    /// `Option` because the model does not mark the member required on either response
272    /// shape, so an absent one must parse rather than fail.
273    pub idle_policy: Option<ops::IdlePolicy>,
274}
275
276impl Microvm {
277    /// The reason the service gave, or a phrase saying it gave none.
278    ///
279    /// A phrase rather than an empty string, because "the service said nothing" and "the
280    /// service said nothing useful" are different diagnoses and a caller reading a message
281    /// has to be able to tell them apart.
282    fn reason(&self) -> &str {
283        self.state_reason.as_deref().unwrap_or("no stateReason")
284    }
285}
286
287impl From<ops::MicrovmResponseWire> for Microvm {
288    fn from(wire: ops::MicrovmResponseWire) -> Self {
289        Self {
290            id: wire.microvm_id,
291            state: wire.state,
292            endpoint: wire.endpoint,
293            image_arn: wire.image_arn,
294            image_version: wire.image_version,
295            state_reason: wire.state_reason,
296            idle_policy: wire.idle_policy,
297        }
298    }
299}
300
301/// The minted proxy token, as a header map (TRAP-7).
302///
303/// # Why a map rather than a string
304///
305/// Because that is what the service answers: `authToken` is a `TokenParts` map, and the
306/// value at `X-aws-proxy-auth` is the header value. A client that treated the response as a
307/// string would send a stringified map and get a rejection that reads like a bad token.
308///
309/// [`ProxyToken::headers`] always emits **both** headers, which is the other half of the
310/// same trap: `X-aws-proxy-auth` without `X-aws-proxy-port` is rejected the same
311/// indistinguishable way.
312#[derive(Clone, Eq, PartialEq)]
313pub struct ProxyToken {
314    value: String,
315    port: u16,
316}
317
318/// Names the headers and the port, never the token value.
319///
320/// A minted proxy token is a credential, and a derived `Debug` would print it into every
321/// log line that formats a [`ProxyToken`], a [`RunMicrovmRequest`] holding one, or an error
322/// chain containing either. The sibling type in [`crate::session::proxy`] made this choice
323/// first; this is the same rule on the control-plane side of the conversion.
324impl std::fmt::Debug for ProxyToken {
325    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
326        f.debug_struct("ProxyToken")
327            .field("headers", &[PROXY_AUTH_HEADER, PROXY_PORT_HEADER])
328            .field("port", &self.port)
329            .finish()
330    }
331}
332
333impl ProxyToken {
334    /// Reads the token out of the service's `authToken` map.
335    ///
336    /// A missing `X-aws-proxy-auth` key is [`ErrorKind::Retryable`] via
337    /// [`crate::error::WireKind::AuthTokenMint`]: minting is inside the request retry path
338    /// (TRAP-9), and a throttle at minute 30 of a long run must not kill a trial that is
339    /// otherwise healthy.
340    pub fn from_map(
341        map: &std::collections::BTreeMap<String, String>,
342        port: u16,
343    ) -> Result<Self, Error> {
344        let value = map.get(PROXY_AUTH_HEADER).ok_or_else(|| {
345            Error::wire(
346                crate::error::WireKind::AuthTokenMint,
347                format!(
348                    "the CreateMicrovmAuthToken response carried no {PROXY_AUTH_HEADER} key. The \
349                     authToken member is a map, not a string, and that key is the header value \
350                     (keys present: {:?}).",
351                    map.keys().collect::<Vec<_>>()
352                ),
353            )
354        })?;
355        Ok(Self {
356            value: value.clone(),
357            port,
358        })
359    }
360
361    /// Both headers every endpoint request needs.
362    ///
363    /// Both, always. See the type docs: sending only the auth header is rejected in a way
364    /// that reads like a bad token.
365    pub fn headers(&self) -> [(&'static str, String); 2] {
366        [
367            (PROXY_AUTH_HEADER, self.value.clone()),
368            (PROXY_PORT_HEADER, self.port.to_string()),
369        ]
370    }
371
372    /// The raw token value.
373    pub fn value(&self) -> &str {
374        &self.value
375    }
376
377    /// The port the token was minted for.
378    pub fn port(&self) -> u16 {
379        self.port
380    }
381}
382
383impl ControlPlane {
384    /// Launches a MicroVM.
385    ///
386    /// TRAP-5 is already closed by [`RunHookPayload`]'s type; the duration range is checked
387    /// here, and the connectors are derived from intents (TRAP-4). The `clientToken` is
388    /// minted from a label (TRAP-1).
389    pub async fn run_microvm(&self, request: RunMicrovmRequest) -> Result<Microvm, Error> {
390        super::require_valid_identifier("imageIdentifier", &request.image_identifier)?;
391        super::require_duration_in_range(request.max_duration_sec)?;
392        // `min: 60` on `IdlePolicyMaxIdleDurationSecondsInteger`. There used to be no guard for
393        // this on the grounds that botocore enforces `min` locally — true of the deleted Python
394        // client and never of this one. See `super::require_idle_duration`.
395        super::require_idle_duration(request.max_idle_sec)?;
396        // Checked before the wire because a pinned launch is what a rollback does, and a
397        // `ValidationException` on the member rather than on the version is the least useful
398        // failure available at that moment. See `super::require_valid_version`.
399        if let Some(version) = request.image_version.as_deref() {
400            super::require_valid_version("imageVersion", version)?;
401        }
402        // `RoleArn` — optional in the model, and every real launch has one. A malformed value
403        // here is a `ValidationException` on a member the caller may not know is being filled
404        // from their infra config, so the refusal names it.
405        if let Some(role) = request.execution_role_arn.as_deref() {
406            super::require_valid_role_arn("executionRoleArn", role)?;
407        }
408
409        // Ingress and egress go in separate members, so they are split by intent rather
410        // than concatenated into one list.
411        let ingress: Vec<String> = request
412            .connectors
413            .iter()
414            .filter(|intent| matches!(intent, super::ConnectorIntent::AllIngress))
415            .map(|intent| intent.arn(&self.region))
416            .collect();
417        let egress: Vec<String> = request
418            .connectors
419            .iter()
420            .filter(|intent| matches!(intent, super::ConnectorIntent::Egress))
421            .map(|intent| intent.arn(&self.region))
422            .collect();
423
424        if ingress.len() + egress.len() > crate::constants::MAX_NETWORK_CONNECTORS {
425            return Err(Error::invalid_arg(format!(
426                "{} network connectors were requested, over the NetworkConnectorList ceiling of \
427                 {} (service model {}).",
428                ingress.len() + egress.len(),
429                crate::constants::MAX_NETWORK_CONNECTORS,
430                crate::constants::MODEL_API_VERSION,
431            )));
432        }
433
434        let wire = ops::RunMicrovmWire {
435            image_identifier: request.image_identifier.clone(),
436            image_version: request.image_version.clone(),
437            execution_role_arn: request.execution_role_arn.clone(),
438            ingress_network_connectors: ingress,
439            // Absent rather than empty: omitting egress is how you get no outbound network.
440            egress_network_connectors: (!egress.is_empty()).then_some(egress),
441            idle_policy: ops::IdlePolicy {
442                max_idle_duration_seconds: request.max_idle_sec,
443                suspended_duration_seconds: request.suspended_sec,
444                auto_resume_enabled: request.auto_resume,
445            },
446            maximum_duration_in_seconds: request.max_duration_sec,
447            run_hook_payload: request.run_hook_payload.as_str().to_string(),
448            client_token: token::run_token(
449                request
450                    .token_scope
451                    .as_deref()
452                    .unwrap_or(&request.image_identifier),
453            ),
454        };
455
456        let call = Call::post_json("RunMicrovm", paths::microvms(), &wire)?;
457        let reply = send_with_retry(self.transport(), call).await?;
458        let launched: ops::MicrovmResponseWire = reply.json("RunMicrovm")?;
459        Ok(launched.into())
460    }
461
462    /// Polls to RUNNING, failing fast on a terminal state (TRAP-8).
463    ///
464    /// The fast failure is the whole value. A VM that reaches a terminal state before
465    /// RUNNING died during startup; polling through it wastes minutes and then reports a
466    /// connection error that hides the cause, and by then the VM is gone so `stateReason` is
467    /// the only evidence left.
468    pub async fn wait_for_running(&self, id: &str, opts: WaitOpts) -> Result<Microvm, Error> {
469        self.wait_for_state(id, &["RUNNING"], &crate::constants::TERMINAL_STATES, opts)
470            .await
471    }
472
473    /// Polls until the VM reaches one of `wanted`, failing fast on any of `fail_on`.
474    ///
475    /// # Why `fail_on` is a parameter rather than always the terminal set
476    ///
477    /// Because `suspend` **wants** SUSPENDED and tolerates TERMINATED: a VM that dies while
478    /// suspending is a state to report, not an exception to raise out of the middle of a
479    /// teardown. The resume path passes the *dead* states instead, because a VM the idle
480    /// policy terminated during suspension never reaches RUNNING — and waiting only for
481    /// RUNNING there burns the full timeout and then reports "never reached RUNNING", a
482    /// timeout message hiding a cause the service had already stated.
483    pub async fn wait_for_state(
484        &self,
485        id: &str,
486        wanted: &[&str],
487        fail_on: &[&str],
488        opts: WaitOpts,
489    ) -> Result<Microvm, Error> {
490        let started = self.clock().elapsed();
491        loop {
492            let got = self.get_microvm(id).await?;
493
494            if wanted.contains(&got.state.as_str()) {
495                return Ok(got);
496            }
497            if fail_on.contains(&got.state.as_str()) {
498                return Err(self.reached_terminal_state(&got, wanted));
499            }
500
501            let elapsed = self.clock().elapsed().saturating_sub(started);
502            if elapsed >= opts.timeout {
503                return Err(timed_out(
504                    &format!(
505                        "microvm {id} never reached {wanted:?} (last state {})",
506                        got.state
507                    ),
508                    elapsed,
509                ));
510            }
511            self.clock().sleep(opts.poll_interval).await;
512        }
513    }
514
515    /// TRAP-8's error: the state **and** the reason, both in the message.
516    ///
517    /// Both because either alone is unactionable. The state says the VM is gone; the reason
518    /// says why, and it is the only evidence that survives the VM.
519    fn reached_terminal_state(&self, got: &Microvm, wanted: &[&str]) -> Error {
520        Error::new(
521            ErrorKind::LaunchDied,
522            format!(
523                "microvm {} reached {} before {wanted:?}: {}. A VM that reaches a terminal state \
524                 before RUNNING died during startup, and for a hook-serving daemon that almost \
525                 always means a lifecycle hook failed — the stateReason above is the only evidence \
526                 left, because the VM is gone (docs/PLATFORM.md, 'A MicroVM that dies during \
527                 startup reports a connection error that hides the cause').",
528                got.id,
529                got.state,
530                got.reason(),
531            ),
532        )
533    }
534
535    /// `GetMicrovm`.
536    ///
537    /// `microvmIdentifier` is a URI parameter, so an empty one collapses the path onto the
538    /// listing and asks a different question — see [`super::require_valid_identifier`]. This is
539    /// the read every wait loop makes, so the check also covers
540    /// [`Self::wait_for_state`] and everything built on it.
541    pub async fn get_microvm(&self, id: &str) -> Result<Microvm, Error> {
542        super::require_valid_identifier("microvmIdentifier", id)?;
543        let call = Call::get("GetMicrovm", paths::microvm(id));
544        let reply = send_with_retry(self.transport(), call).await?;
545        let got: ops::MicrovmResponseWire = reply.json("GetMicrovm")?;
546        Ok(got.into())
547    }
548
549    /// `ListMicrovms`, read to its last page.
550    ///
551    /// # Why every page
552    ///
553    /// `maxResults` caps at 50, so a fleet above 50 VMs read from one page is a
554    /// **confidently wrong** answer rather than a missing one: the caller gets a list that
555    /// looks complete, and a VM absent from it is a VM nothing here will terminate. That is
556    /// the same argument [`ops::ListImagesResponseWire`] makes for the image listing, and a
557    /// fleet listing is the one a teardown reads.
558    pub async fn list_microvms(&self) -> Result<Vec<ops::MicrovmItemWire>, Error> {
559        let mut items = Vec::new();
560        let mut next_token: Option<String> = None;
561        loop {
562            let call = Call::get("ListMicrovms", paths::microvms_list(next_token.as_deref()));
563            let reply = send_with_retry(self.transport(), call).await?;
564            let page: ops::ListMicrovmsResponseWire = reply.json("ListMicrovms")?;
565            items.extend(page.items);
566            match page.next_token {
567                Some(token) => next_token = Some(token),
568                None => return Ok(items),
569            }
570        }
571    }
572
573    /// `SuspendMicrovm`. Freezes the VM; the caller waits for SUSPENDED.
574    pub async fn suspend(&self, id: &str) -> Result<(), Error> {
575        super::require_valid_identifier("microvmIdentifier", id)?;
576        let call = Call::post_empty("SuspendMicrovm", paths::suspend(id));
577        send_with_retry(self.transport(), call).await?;
578        Ok(())
579    }
580
581    /// `ResumeMicrovm`. Thaws the VM; no token re-delivery and no re-bootstrap.
582    pub async fn resume(&self, id: &str) -> Result<(), Error> {
583        super::require_valid_identifier("microvmIdentifier", id)?;
584        let call = Call::post_empty("ResumeMicrovm", paths::resume(id));
585        send_with_retry(self.transport(), call).await?;
586        Ok(())
587    }
588
589    /// `TerminateMicrovm`.
590    ///
591    /// The identifier check matters most on this one, and for a reason the others do not share:
592    /// the path is `DELETE /microvms/{microvmIdentifier}`, so an empty identifier is a `DELETE`
593    /// addressed at the **collection**. There is no operation on that path and the service
594    /// refuses it, but "send a delete at the fleet and rely on the service to refuse" is not a
595    /// thing to leave to the service.
596    pub async fn terminate(&self, id: &str) -> Result<(), Error> {
597        super::require_valid_identifier("microvmIdentifier", id)?;
598        let call = Call::delete("TerminateMicrovm", paths::microvm(id));
599        send_with_retry(self.transport(), call).await?;
600        Ok(())
601    }
602
603    /// Mints a proxy token for the agent port (TRAP-7, TRAP-9).
604    ///
605    /// `expirationInMinutes` is capped at [`MAX_TOKEN_MINUTES`] here rather than passed
606    /// through, because over-asking is rejected and the rejection reads like a bad request
607    /// rather than like a ceiling. The *refresh* interval — half the ceiling — belongs to
608    /// the endpoint client that holds the token (T-W2-5), not here.
609    ///
610    /// Scoped to the agent port alone, which is right for every request this client makes on
611    /// its own behalf and **wrong for a caller reaching another port on the same VM**. That
612    /// caller wants [`Self::mint_auth_token_for`]; see [`ops::PortSpecification`] for the
613    /// measurement that made the distinction necessary.
614    pub async fn mint_auth_token(&self, id: &str) -> Result<ProxyToken, Error> {
615        self.mint_auth_token_for(id, &[ops::PortSpecification::port(self.port())])
616            .await
617    }
618
619    /// Mints a proxy token scoped to `ports`, whatever they are.
620    ///
621    /// The agent port is **not** added for you. A caller naming ports is answering the
622    /// question the token asks, and silently widening the scope would hand back a
623    /// credential broader than the one requested — the opposite of the mistake this exists
624    /// to fix, and a worse one, since it is invisible. A caller that needs both the agent
625    /// port and a workload port names both, which is what [`ProxyAuth`] does.
626    ///
627    /// The returned token still records the agent port as its *default* header port, because
628    /// that is the port a request carries when nobody names one.
629    ///
630    /// [`ProxyAuth`]: crate::session::ProxyAuth
631    pub async fn mint_auth_token_for(
632        &self,
633        id: &str,
634        ports: &[ops::PortSpecification],
635    ) -> Result<ProxyToken, Error> {
636        super::require_valid_identifier("microvmIdentifier", id)?;
637        if ports.is_empty() {
638            // The model declares `min: 1` on `allowedPorts`, so an empty list is a
639            // round-trip that comes back as a validation error naming a member the caller
640            // did not know it was sending. Refused here, with the reason.
641            return Err(Error::new(
642                ErrorKind::Precondition,
643                "a proxy token must allow at least one port ('allowedPorts' declares min: 1). \
644                 Name the ports this token is for — the agent port plus any workload port \
645                 reached through the endpoint.",
646            ));
647        }
648        // `PortNumber` is `min: 1`, and the value that reaches here as a 0 is not a typo — it is
649        // a caller who took `ControlPlane::with_port(0)`'s default port, or who passed a
650        // `SocketAddr`'s port before binding. A token minted for port 0 authorizes nothing, and
651        // the proxy's refusal is `403 Access to port denied`, which reads like the token being
652        // wrong rather than its scope being empty (measured 2026-08-15, see
653        // `ops::PortSpecification`). Both ends of a range are checked, because `PortRange`'s two
654        // members name the same shape and a range starting at 0 is the same defect.
655        for spec in ports {
656            match spec {
657                ops::PortSpecification::One { port } => {
658                    super::require_valid_port("allowedPorts[].port", *port)?;
659                }
660                ops::PortSpecification::Range { range } => {
661                    super::require_valid_port("allowedPorts[].range.startPort", range.start_port)?;
662                    super::require_valid_port("allowedPorts[].range.endPort", range.end_port)?;
663                }
664                // `allPorts` names no number, so there is nothing to bound.
665                ops::PortSpecification::All { .. } => {}
666            }
667        }
668        let wire = ops::CreateAuthTokenWire {
669            expiration_in_minutes: MAX_TOKEN_MINUTES,
670            allowed_ports: ports.to_vec(),
671        };
672        let call = Call::post_json("CreateMicrovmAuthToken", paths::auth_token(id), &wire)?;
673        let reply = send_with_retry(self.transport(), call).await?;
674        let minted: ops::CreateAuthTokenResponseWire = reply.json("CreateMicrovmAuthToken")?;
675        ProxyToken::from_map(&minted.auth_token, self.port())
676    }
677}
678
679/// The states a suspend may end in.
680///
681/// TERMINATED is *wanted* rather than a failure: a VM that dies while suspending is a state
682/// to report, not an exception to raise out of the middle of a teardown.
683pub const SUSPEND_WANTED: [&str; 2] = ["SUSPENDED", "TERMINATED"];
684
685#[cfg(test)]
686mod tests {
687    use std::sync::Arc;
688
689    use super::super::fake::{self as fake, Answer, FakeControlPlane, TestClock};
690    use super::*;
691    use crate::region::Region;
692
693    fn planted() -> (ControlPlane, Arc<FakeControlPlane>, Arc<TestClock>) {
694        let fake = Arc::new(FakeControlPlane::new());
695        let clock = Arc::new(TestClock::new());
696        let plane = ControlPlane::with_transport(fake.clone(), Region::UsEast1, clock.clone());
697        (plane, fake, clock)
698    }
699
700    /// **TRAP-5, the boundary.** 4096 bytes passes, 4097 is rejected — inclusive, as
701    /// measured 2026-08-07.
702    ///
703    /// **Falsification** — change the comparison to `<` and the 4096 case fails; change the
704    /// ceiling to 16384 (the number the docs and the model's own documentation string claim)
705    /// and the 4097 case fails.
706    #[test]
707    fn the_payload_ceiling_is_four_thousand_ninety_six_bytes_inclusive() {
708        let at_ceiling = "a".repeat(4096);
709        let payload = RunHookPayload::new(at_ceiling.clone()).expect("4096 bytes fits");
710        assert_eq!(payload.len(), 4096);
711        assert_eq!(payload.as_str(), at_ceiling);
712
713        let over = "a".repeat(4097);
714        let error = RunHookPayload::new(over).expect_err("4097 bytes does not fit");
715        assert_eq!(error.kind(), ErrorKind::InvalidArg);
716        let message = error.to_string();
717        assert!(message.contains("4097 bytes"), "{message}");
718        assert!(
719            message.contains("ceiling of 4096"),
720            "the message must name the service-model ceiling: {message}"
721        );
722        assert!(message.contains("2025-09-09"), "{message}");
723        assert!(
724            message.contains("wrong by 4x"),
725            "the 16 KB claim has to be named as wrong: {message}"
726        );
727    }
728
729    /// **TRAP-5, the local-refusal half.** An over-ceiling payload means the fake records
730    /// **zero** calls: the launch is refused before any control-plane call.
731    #[tokio::test]
732    async fn an_over_ceiling_payload_reaches_no_control_plane_call() {
733        let (plane, fake, _) = planted();
734
735        // The payload type refuses first, so there is no way to even build the request.
736        let refused = RunHookPayload::new("a".repeat(4097));
737        assert!(refused.is_err());
738        assert_eq!(fake.calls().len(), 0);
739
740        // And the at-ceiling one does launch, so the guard is not simply refusing
741        // everything.
742        fake.answer(
743            "RunMicrovm",
744            Answer::ok(fake::microvm_response("PENDING", None)),
745        );
746        let payload = RunHookPayload::new("a".repeat(4096)).expect("fits");
747        plane
748            .run_microvm(RunMicrovmRequest::new("arn:image", payload))
749            .await
750            .expect("launches");
751        assert_eq!(fake.call_count("RunMicrovm"), 1);
752    }
753
754    /// The ceiling is measured in **bytes**, not characters. A payload of 2049 two-byte
755    /// characters is 4098 bytes and must be refused even though it is 2049 characters.
756    #[test]
757    fn the_ceiling_counts_utf8_bytes_rather_than_characters() {
758        let two_byte_chars = "é".repeat(2049);
759        assert_eq!(
760            two_byte_chars.chars().count(),
761            2049,
762            "well under 4096 chars"
763        );
764        assert_eq!(two_byte_chars.len(), 4098, "but over 4096 bytes");
765
766        let error = RunHookPayload::new(two_byte_chars).expect_err("bytes are what count");
767        assert!(error.to_string().contains("4098 bytes"), "{error}");
768
769        // 2048 of them is exactly 4096 bytes, which fits.
770        let exact = "é".repeat(2048);
771        assert_eq!(exact.len(), 4096);
772        RunHookPayload::new(exact).expect("exactly at the ceiling");
773    }
774
775    /// The identity material costs exactly its declared budget, and a launch without it is
776    /// byte-for-byte what the older constructors built.
777    ///
778    /// Measured by difference rather than trusted from the constant's comment, the same
779    /// discipline `env_contribution` applies: the ceiling is on the serialized string, so
780    /// only the serialized string can say what the material costs. The compatibility half
781    /// matters just as much — a pinned daemon that has never heard of identity must see no
782    /// change from a client that did not ask for it.
783    #[test]
784    fn the_identity_material_costs_its_declared_budget_and_absence_costs_nothing() {
785        let env = std::collections::HashMap::new();
786        let without = RunHookPayload::for_launch("tok", &env).expect("fits");
787        let none_asked = RunHookPayload::for_launch_with_identity("tok", &env, None).expect("fits");
788        assert_eq!(
789            without.as_str(),
790            none_asked.as_str(),
791            "no identity must produce byte-for-byte the payload this client always sent"
792        );
793
794        let identity = crate::identity::LaunchIdentity::from_seeds([7_u8; 32], [9_u8; 32]);
795        let with =
796            RunHookPayload::for_launch_with_identity("tok", &env, Some(&identity)).expect("fits");
797        assert_eq!(
798            with.len() - without.len(),
799            protocol::identity::IDENTITY_PAYLOAD_BYTES,
800            "the budget constant must equal what the material really costs on the wire"
801        );
802
803        // And the payload parses as the daemon parses it, with both halves present.
804        let parsed = protocol::hook::RunHook::parse(with.as_str()).expect("the daemon can read it");
805        assert_eq!(
806            parsed.identity_seed.as_deref(),
807            Some(identity.seed_field().as_str())
808        );
809        assert_eq!(
810            parsed.identity_host_public_key.as_deref(),
811            Some(identity.host_public_field().as_str())
812        );
813    }
814
815    /// The agent-token payload is the shape the daemon parses, and an oversized token is
816    /// caught even though the client builds the JSON — because the token is caller-supplied.
817    #[test]
818    fn the_agent_token_payload_is_checked_even_though_the_client_builds_it() {
819        let payload = RunHookPayload::for_agent_token("bearer-token-value").expect("fits");
820        assert_eq!(payload.as_str(), r#"{"agent_token":"bearer-token-value"}"#);
821
822        // A caller passing a credential bundle rather than a bearer token is who this
823        // catches.
824        let huge = "x".repeat(4096);
825        let error = RunHookPayload::for_agent_token(&huge)
826            .expect_err("the JSON wrapper pushes it over the ceiling");
827        assert_eq!(error.kind(), ErrorKind::InvalidArg);
828        assert!(
829            error.to_string().contains("one bearer token fits"),
830            "{error}"
831        );
832    }
833
834    /// An empty launch env produces byte-for-byte the payload the token-only
835    /// constructor always produced, so a caller who never passes one is unaffected by
836    /// the field existing — and a daemon baked into an older image sees no new key.
837    ///
838    /// **Falsification** — always emit `"env"` and this fails on both assertions.
839    #[test]
840    fn an_empty_launch_env_emits_the_payload_the_token_only_constructor_did() {
841        let with_empty = RunHookPayload::for_launch("tok", &std::collections::HashMap::new())
842            .expect("an empty env fits");
843        let token_only = RunHookPayload::for_agent_token("tok").expect("fits");
844        assert_eq!(with_empty.as_str(), token_only.as_str());
845        assert_eq!(with_empty.as_str(), r#"{"agent_token":"tok"}"#);
846        assert!(
847            !with_empty.as_str().contains("env"),
848            "an empty env must not appear on the wire at all: {}",
849            with_empty.as_str()
850        );
851    }
852
853    /// A launch env is carried in the payload the daemon parses.
854    #[test]
855    fn a_launch_env_rides_in_the_payload_the_daemon_parses() {
856        let mut env = std::collections::HashMap::new();
857        env.insert("ANTHROPIC_BASE_URL".to_string(), "https://x".to_string());
858        let payload = RunHookPayload::for_launch("tok", &env).expect("fits");
859
860        // Parsed rather than string-compared, since a HashMap has no key order.
861        let value: serde_json::Value =
862            serde_json::from_str(payload.as_str()).expect("the payload is JSON");
863        assert_eq!(value["agent_token"], "tok");
864        assert_eq!(value["env"]["ANTHROPIC_BASE_URL"], "https://x");
865    }
866
867    /// **The local 4096-byte refusal for a launch env.**
868    ///
869    /// The reason this is the case worth its own test rather than a repeat of the
870    /// ceiling test above: one bearer token has always fit with room to spare, so
871    /// before `env` existed the ceiling was effectively unreachable through the typed
872    /// constructor. A launch env is what makes it reachable, and botocore does not
873    /// enforce it client-side — the oversized request goes to the wire and comes back
874    /// as a `ValidationException` on a member the caller did not know they were
875    /// filling (`docs/PLATFORM.md`). So this refusal is the only local signal there is.
876    ///
877    /// The message must carry the byte count, the ceiling, and the env's share of it.
878    /// Without the share, a caller reading "4128 bytes, ceiling 4096" cannot tell
879    /// whether to shorten the token or drop a variable.
880    ///
881    /// **Falsification** — return `Ok` from `for_launch` without calling `new`, or drop
882    /// the byte-count clause from the message, and this goes red on the specific
883    /// assertion.
884    #[test]
885    fn an_over_ceiling_launch_env_is_refused_locally_with_the_byte_count() {
886        let mut env = std::collections::HashMap::new();
887        // Comfortably over on its own, the way a set of session credentials is.
888        env.insert("CREDENTIALS".to_string(), "c".repeat(4096));
889
890        // The expected figure is derived from the same serialization the constructor
891        // does rather than written as a literal, because a literal here asserts my
892        // arithmetic about JSON framing and the point is the *count*, not the framing.
893        let expected = serde_json::json!({ "agent_token": "tok", "env": &env })
894            .to_string()
895            .len();
896        assert!(
897            expected > 4096,
898            "the fixture must actually be over: {expected}"
899        );
900
901        let error = RunHookPayload::for_launch("tok", &env)
902            .expect_err("a credential-scale launch env does not fit");
903        assert_eq!(error.kind(), ErrorKind::InvalidArg);
904        let message = error.to_string();
905
906        assert!(
907            message.contains("ceiling of 4096"),
908            "the ceiling has to be named: {message}"
909        );
910        assert!(
911            message.contains(&format!("{expected} bytes")),
912            "the actual byte count has to be named: {message}"
913        );
914        assert!(
915            message.contains("launch env contributed"),
916            "the env's share of the budget is the actionable half: {message}"
917        );
918        assert!(
919            message.contains("1 variable(s)"),
920            "the variable count has to be named: {message}"
921        );
922    }
923
924    /// The refusal fires on the *serialized* payload, so a caller who measured their own
925    /// values would be measuring the wrong thing.
926    ///
927    /// Two variables whose raw bytes sum to 4060 — under the ceiling — go over it once
928    /// the JSON framing, the key names, and the quotes are counted. A check on the map
929    /// rather than on the string would accept this and the launch would fail at AWS.
930    #[test]
931    fn the_launch_env_ceiling_counts_the_serialized_payload_and_not_the_raw_values() {
932        let mut env = std::collections::HashMap::new();
933        env.insert("A".to_string(), "a".repeat(2030));
934        env.insert("B".to_string(), "b".repeat(2030));
935        let raw: usize = env.values().map(String::len).sum();
936        assert_eq!(raw, 4060, "the raw values are under the ceiling");
937        assert!(
938            raw <= crate::constants::MAX_RUN_HOOK_PAYLOAD_BYTES,
939            "the fixture only means something if the raw sum fits: {raw}"
940        );
941
942        let error = RunHookPayload::for_launch("tok", &env)
943            .expect_err("the serialized payload is over even though the raw values are not");
944        assert!(error.to_string().contains("2 variable(s)"), "{error}");
945
946        // And the boundary is still inclusive with an env in play: trimmed to fit, the
947        // same shape succeeds — so the refusal is a ceiling and not a blanket ban.
948        let mut fits = std::collections::HashMap::new();
949        fits.insert("A".to_string(), "a".repeat(2000));
950        fits.insert("B".to_string(), "b".repeat(2000));
951        let payload = RunHookPayload::for_launch("tok", &fits).expect("4000-odd bytes fits");
952        assert!(payload.len() <= 4096, "{}", payload.len());
953    }
954
955    /// An empty payload is legal: the model's minimum is 0.
956    #[test]
957    fn an_empty_payload_is_legal() {
958        let payload = RunHookPayload::new("").expect("min is 0");
959        assert!(payload.is_empty());
960        assert_eq!(payload.len(), 0);
961    }
962
963    /// The launch request lands on the model's path with the model's member names, and the
964    /// connectors are derived ARNs split across the two members (TRAP-4).
965    #[tokio::test]
966    async fn the_launch_emits_derived_connector_arns_in_the_right_members() {
967        let (plane, fake, _) = planted();
968        fake.answer(
969            "RunMicrovm",
970            Answer::ok(fake::microvm_response("PENDING", None)),
971        );
972
973        let payload = RunHookPayload::for_agent_token("token").expect("fits");
974        let request = RunMicrovmRequest::new("arn:image", payload).with_egress();
975        plane.run_microvm(request).await.expect("launches");
976
977        let calls = fake.calls();
978        assert_eq!(calls[0].path, "/2025-09-09/microvms");
979        assert_eq!(calls[0].method, super::super::transport::Method::Post);
980
981        let body = fake.first_body("RunMicrovm");
982        assert_eq!(
983            body["ingressNetworkConnectors"],
984            serde_json::json!([
985                "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:ALL_INGRESS"
986            ])
987        );
988        assert_eq!(
989            body["egressNetworkConnectors"],
990            serde_json::json!([
991                "arn:aws:lambda:us-east-1:aws:network-connector:aws-network-connector:INTERNET_EGRESS"
992            ])
993        );
994        assert_eq!(body["runHookPayload"], r#"{"agent_token":"token"}"#);
995        assert_eq!(body["maximumDurationInSeconds"], 3600);
996    }
997
998    /// Without egress, the member is **absent** — which is how you get a VM with no
999    /// outbound network.
1000    #[tokio::test]
1001    async fn a_launch_without_egress_omits_the_member_entirely() {
1002        let (plane, fake, _) = planted();
1003        fake.answer(
1004            "RunMicrovm",
1005            Answer::ok(fake::microvm_response("PENDING", None)),
1006        );
1007        let payload = RunHookPayload::for_agent_token("token").expect("fits");
1008        plane
1009            .run_microvm(RunMicrovmRequest::new("arn:image", payload))
1010            .await
1011            .expect("launches");
1012
1013        let body = fake.first_body("RunMicrovm");
1014        assert!(
1015            body.get("egressNetworkConnectors").is_none(),
1016            "omitting egress is the whole mechanism: {body}"
1017        );
1018        assert!(body.get("ingressNetworkConnectors").is_some());
1019    }
1020
1021    /// **A pinned `imageVersion` reaches the wire, and an unpinned launch omits the member.**
1022    ///
1023    /// The absence half is the one that matters for compatibility: an unpinned launch has to
1024    /// emit byte-for-byte what this client always sent, so a `"imageVersion": null` on every
1025    /// launch would be a new member on a request that has worked for months. The presence half
1026    /// is what makes a canary a canary — the launch goes against the version named rather than
1027    /// against whatever became latest while it was starting.
1028    ///
1029    /// **Falsification** — run 2026-08-16. Drop the `image_version` assignment in
1030    /// `run_microvm` and the pinned assertion goes red with the member absent; remove
1031    /// `skip_serializing_if` from the wire field and the absence assertion goes red with a
1032    /// `null`.
1033    #[tokio::test]
1034    async fn a_pinned_launch_version_reaches_the_wire_and_an_unpinned_one_omits_the_member() {
1035        let (plane, fake, _) = planted();
1036        fake.answer(
1037            "RunMicrovm",
1038            Answer::ok(fake::microvm_response("PENDING", None)),
1039        );
1040        let payload = RunHookPayload::for_agent_token("token").expect("fits");
1041        plane
1042            .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_image_version("2.0"))
1043            .await
1044            .expect("launches");
1045
1046        let body = fake.first_body("RunMicrovm");
1047        assert_eq!(body["imageVersion"], "2.0");
1048        assert_eq!(
1049            body["imageIdentifier"], "arn:image",
1050            "pinning a version does not replace the identifier; both are sent"
1051        );
1052
1053        let (plane, fake, _) = planted();
1054        fake.answer(
1055            "RunMicrovm",
1056            Answer::ok(fake::microvm_response("PENDING", None)),
1057        );
1058        let payload = RunHookPayload::for_agent_token("token").expect("fits");
1059        plane
1060            .run_microvm(RunMicrovmRequest::new("arn:image", payload))
1061            .await
1062            .expect("launches");
1063        assert!(
1064            fake.first_body("RunMicrovm").get("imageVersion").is_none(),
1065            "an unpinned launch must send what this client always sent: {}",
1066            fake.first_body("RunMicrovm")
1067        );
1068    }
1069
1070    /// An invalid `imageVersion` is refused before any call — including on the launch path,
1071    /// which is where a rollback happens.
1072    ///
1073    /// A `ValidationException` about the request rather than about the version, arriving at the
1074    /// moment someone is re-pinning away from a bad build, is the least useful failure
1075    /// available. So the `Version` shape's three constraints are checked here.
1076    ///
1077    /// **Falsification** — run 2026-08-16. Delete the `require_valid_version` call from
1078    /// `run_microvm` and the zero-call assertion goes red.
1079    #[tokio::test]
1080    async fn an_invalid_launch_version_reaches_no_control_plane_call() {
1081        for bad in ["", "2.0\n", "a b"] {
1082            let (plane, fake, _) = planted();
1083            let payload = RunHookPayload::for_agent_token("token").expect("fits");
1084            let error = plane
1085                .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_image_version(bad))
1086                .await
1087                .expect_err(&format!("{bad:?} is not a legal Version"));
1088            assert_eq!(error.kind(), ErrorKind::InvalidArg, "{bad:?}");
1089            assert!(
1090                error.to_string().contains("imageVersion"),
1091                "{bad:?}: {error}"
1092            );
1093            assert_eq!(fake.calls().len(), 0, "{bad:?} must not reach the wire");
1094        }
1095    }
1096
1097    /// The connectors reaching the wire are ARNs, never bare names — a bare name is rejected
1098    /// with "Malformed network connector ARN", and it is the value that reads most natural
1099    /// to write.
1100    #[tokio::test]
1101    async fn no_bare_connector_name_reaches_the_wire() {
1102        let (plane, fake, _) = planted();
1103        fake.answer(
1104            "RunMicrovm",
1105            Answer::ok(fake::microvm_response("PENDING", None)),
1106        );
1107        let payload = RunHookPayload::for_agent_token("token").expect("fits");
1108        plane
1109            .run_microvm(RunMicrovmRequest::new("arn:image", payload).with_egress())
1110            .await
1111            .expect("launches");
1112
1113        let body = fake.first_body("RunMicrovm");
1114        for member in ["ingressNetworkConnectors", "egressNetworkConnectors"] {
1115            for value in body[member].as_array().expect("a list") {
1116                let text = value.as_str().expect("a string");
1117                assert!(text.starts_with("arn:aws:lambda:"), "{member}: {text}");
1118                assert_ne!(text, "ALL_INGRESS");
1119                assert_ne!(text, "INTERNET_EGRESS");
1120            }
1121        }
1122    }
1123
1124    /// An out-of-range duration is refused before any call.
1125    #[tokio::test]
1126    async fn an_out_of_range_duration_reaches_no_control_plane_call() {
1127        let (plane, fake, _) = planted();
1128        let payload = RunHookPayload::for_agent_token("token").expect("fits");
1129        let mut request = RunMicrovmRequest::new("arn:image", payload);
1130        request.max_duration_sec = 28_801;
1131
1132        let error = plane
1133            .run_microvm(request)
1134            .await
1135            .expect_err("over the ceiling");
1136        assert_eq!(error.kind(), ErrorKind::InvalidArg);
1137        assert_eq!(fake.calls().len(), 0);
1138    }
1139
1140    /// **TRAP-8.** A VM that reaches a terminal state before RUNNING is rejected with the
1141    /// state **and** the `stateReason` in the message.
1142    ///
1143    /// The message is asserted rather than merely the error, because a timeout also produces
1144    /// an error — so "an error occurred" would pass against the exact defect this closes.
1145    ///
1146    /// **Falsification** — remove the `fail_on` branch from `wait_for_state` and this test
1147    /// fails with an `ErrorKind::Timeout` whose message names neither the state nor the
1148    /// reason. Verified; see the packet's guard proofs.
1149    #[tokio::test]
1150    async fn a_terminal_state_before_running_carries_both_the_state_and_the_reason() {
1151        let (plane, fake, _) = planted();
1152        fake.answer(
1153            "GetMicrovm",
1154            Answer::ok(fake::microvm_response(
1155                "TERMINATED",
1156                Some("run hook returned 500"),
1157            )),
1158        );
1159
1160        let error = plane
1161            .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1162            .await
1163            .expect_err("a dead VM must not be waited out");
1164
1165        assert_eq!(error.kind(), ErrorKind::LaunchDied);
1166        assert_eq!(error.code(), "ERR_LAUNCH_DIED");
1167        let message = error.to_string();
1168        assert!(
1169            message.contains("TERMINATED"),
1170            "the state must be attached: {message}"
1171        );
1172        assert!(
1173            message.contains("run hook returned 500"),
1174            "the stateReason must be attached: {message}"
1175        );
1176        assert!(
1177            message.contains("lifecycle hook failed"),
1178            "the likely cause is worth naming: {message}"
1179        );
1180        assert!(
1181            fake.call_count("GetMicrovm") == 1,
1182            "it fails fast rather than polling to the deadline"
1183        );
1184    }
1185
1186    /// Every terminal state fails the launch fast, not just TERMINATED. SUSPENDED reached
1187    /// before RUNNING is also a death — the VM never came up.
1188    ///
1189    /// The states are read from `constants::TERMINAL_STATES` rather than listed, so a state
1190    /// added there is covered here without a second edit — and the count is asserted so the
1191    /// loop cannot silently become empty.
1192    #[tokio::test]
1193    async fn every_terminal_state_fails_a_launch_fast() {
1194        assert_eq!(crate::constants::TERMINAL_STATES.len(), 4);
1195        for state in crate::constants::TERMINAL_STATES {
1196            let (plane, fake, _) = planted();
1197            fake.answer(
1198                "GetMicrovm",
1199                Answer::ok(fake::microvm_response(state, Some("hook timeout"))),
1200            );
1201            let error = plane
1202                .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1203                .await
1204                .expect_err(&format!("{state} before RUNNING is a death"));
1205            assert_eq!(error.kind(), ErrorKind::LaunchDied, "{state}");
1206            assert!(error.to_string().contains(state), "{state}: {error}");
1207            assert!(
1208                error.to_string().contains("hook timeout"),
1209                "{state}: {error}"
1210            );
1211            assert_eq!(
1212                fake.call_count("GetMicrovm"),
1213                1,
1214                "{state} must fail fast rather than polling to the deadline"
1215            );
1216        }
1217    }
1218
1219    /// A terminal state with **no** `stateReason` says so rather than printing an empty
1220    /// string: "the service said nothing" is a different diagnosis from "the service said
1221    /// nothing useful".
1222    #[tokio::test]
1223    async fn a_missing_state_reason_is_named_rather_than_printed_empty() {
1224        let (plane, fake, _) = planted();
1225        fake.answer(
1226            "GetMicrovm",
1227            Answer::ok(fake::microvm_response("TERMINATED", None)),
1228        );
1229
1230        let error = plane
1231            .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1232            .await
1233            .expect_err("still a death");
1234        let message = error.to_string();
1235        assert!(message.contains("no stateReason"), "{message}");
1236        // The failure this guards against is an absent reason rendering as an empty string,
1237        // which reads as the service having answered with nothing rather than not having
1238        // answered. `: .` is what that looks like in this message's shape.
1239        assert!(
1240            !message.contains(r#"["RUNNING"]: ."#),
1241            "an absent reason must not render as an empty string: {message}"
1242        );
1243        assert_eq!(error.kind(), ErrorKind::LaunchDied);
1244    }
1245
1246    /// The wait returns on RUNNING, having polled through PENDING.
1247    #[tokio::test]
1248    async fn the_launch_wait_polls_through_pending_and_returns_on_running() {
1249        let (plane, fake, _) = planted();
1250        fake.answer(
1251            "GetMicrovm",
1252            Answer::ok(fake::microvm_response("PENDING", None)),
1253        )
1254        .answer(
1255            "GetMicrovm",
1256            Answer::ok(fake::microvm_response("RUNNING", None)),
1257        );
1258
1259        let vm = plane
1260            .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1261            .await
1262            .expect("reaches RUNNING");
1263        assert_eq!(vm.state, "RUNNING");
1264        assert_eq!(
1265            vm.endpoint,
1266            "https://mvm-abc123.microvm.us-east-1.amazonaws.com"
1267        );
1268        assert_eq!(fake.call_count("GetMicrovm"), 2);
1269    }
1270
1271    /// A launch that never comes up ends at its deadline with a `Timeout` — which is a
1272    /// *different* error from TRAP-8's, and the distinction is the whole reason the TRAP-8
1273    /// test asserts on the message.
1274    #[tokio::test]
1275    async fn a_launch_that_never_comes_up_times_out_rather_than_reporting_a_death() {
1276        let (plane, fake, _) = planted();
1277        fake.answer(
1278            "GetMicrovm",
1279            Answer::ok(fake::microvm_response("PENDING", None)),
1280        );
1281
1282        let error = plane
1283            .wait_for_running("mvm-abc123", WaitOpts::for_launch())
1284            .await
1285            .expect_err("the deadline elapses");
1286        assert_eq!(error.kind(), ErrorKind::Timeout);
1287        assert_ne!(
1288            error.kind(),
1289            ErrorKind::LaunchDied,
1290            "a timeout is not a death, and conflating them is what hid the cause"
1291        );
1292        assert!(error.to_string().contains("PENDING"), "{error}");
1293    }
1294
1295    /// The resume path fails fast on the **dead** states and waits through SUSPENDED —
1296    /// which is the state a resume is called from, so failing on it would fail every resume.
1297    #[tokio::test]
1298    async fn the_resume_wait_tolerates_suspended_and_fails_on_terminated() {
1299        let (plane, fake, _) = planted();
1300        fake.answer(
1301            "GetMicrovm",
1302            Answer::ok(fake::microvm_response("SUSPENDED", None)),
1303        )
1304        .answer(
1305            "GetMicrovm",
1306            Answer::ok(fake::microvm_response("RUNNING", None)),
1307        );
1308
1309        let vm = plane
1310            .wait_for_state(
1311                "mvm-abc123",
1312                &["RUNNING"],
1313                &crate::constants::DEAD_STATES,
1314                WaitOpts::for_launch(),
1315            )
1316            .await
1317            .expect("SUSPENDED is a waypoint on the resume path");
1318        assert_eq!(vm.state, "RUNNING");
1319
1320        let (plane, fake, _) = planted();
1321        fake.answer(
1322            "GetMicrovm",
1323            Answer::ok(fake::microvm_response(
1324                "TERMINATED",
1325                Some("suspended window elapsed"),
1326            )),
1327        );
1328        let error = plane
1329            .wait_for_state(
1330                "mvm-abc123",
1331                &["RUNNING"],
1332                &crate::constants::DEAD_STATES,
1333                WaitOpts::for_launch(),
1334            )
1335            .await
1336            .expect_err("a terminated VM never reaches RUNNING");
1337        assert_eq!(error.kind(), ErrorKind::LaunchDied);
1338        assert!(
1339            error.to_string().contains("suspended window elapsed"),
1340            "{error}"
1341        );
1342    }
1343
1344    /// A suspend **wants** TERMINATED as well as SUSPENDED: a VM that dies while suspending
1345    /// is a state to report, not an exception out of a teardown.
1346    #[tokio::test]
1347    async fn a_suspend_treats_terminated_as_a_state_to_report() {
1348        let (plane, fake, _) = planted();
1349        fake.answer(
1350            "GetMicrovm",
1351            Answer::ok(fake::microvm_response("TERMINATED", None)),
1352        );
1353
1354        let vm = plane
1355            .wait_for_state("mvm-abc123", &SUSPEND_WANTED, &[], WaitOpts::for_launch())
1356            .await
1357            .expect("a suspend reports the state it reached");
1358        assert_eq!(vm.state, "TERMINATED");
1359    }
1360
1361    /// The lifecycle calls land on the model's paths and methods. `POST` for suspend and
1362    /// resume, `DELETE` for terminate — transcribed from each operation's `http` trait.
1363    #[tokio::test]
1364    async fn the_lifecycle_calls_use_the_models_paths_and_methods() {
1365        let (plane, fake, _) = planted();
1366        fake.answer("SuspendMicrovm", Answer::ok(fake::empty_response()))
1367            .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1368            .answer("TerminateMicrovm", Answer::ok(fake::empty_response()));
1369
1370        plane.suspend("mvm-1").await.expect("suspends");
1371        plane.resume("mvm-1").await.expect("resumes");
1372        plane.terminate("mvm-1").await.expect("terminates");
1373
1374        let calls = fake.calls();
1375        use super::super::transport::Method;
1376        assert_eq!(
1377            calls
1378                .iter()
1379                .map(|call| (call.operation, call.method, call.path.as_str()))
1380                .collect::<Vec<_>>(),
1381            [
1382                (
1383                    "SuspendMicrovm",
1384                    Method::Post,
1385                    "/2025-09-09/microvms/mvm-1/suspend"
1386                ),
1387                (
1388                    "ResumeMicrovm",
1389                    Method::Post,
1390                    "/2025-09-09/microvms/mvm-1/resume"
1391                ),
1392                (
1393                    "TerminateMicrovm",
1394                    Method::Delete,
1395                    "/2025-09-09/microvms/mvm-1"
1396                ),
1397            ]
1398        );
1399    }
1400
1401    /// **TRAP-7.** The minted token is read out of the `authToken` **map**, and
1402    /// [`ProxyToken::headers`] emits both headers.
1403    #[tokio::test]
1404    async fn the_minted_token_comes_from_the_map_and_carries_both_headers() {
1405        let (plane, fake, _) = planted();
1406        fake.answer(
1407            "CreateMicrovmAuthToken",
1408            Answer::ok(fake::auth_token_response("opaque-proxy-token")),
1409        );
1410
1411        let token = plane.mint_auth_token("mvm-1").await.expect("mints");
1412        assert_eq!(token.value(), "opaque-proxy-token");
1413        assert_eq!(token.port(), 9000);
1414
1415        let headers = token.headers();
1416        assert_eq!(
1417            headers[0],
1418            ("X-aws-proxy-auth", "opaque-proxy-token".to_string())
1419        );
1420        assert_eq!(
1421            headers[1],
1422            ("X-aws-proxy-port", "9000".to_string()),
1423            "the auth header without the port header is rejected in a way that reads like a \
1424             bad token"
1425        );
1426
1427        let body = fake.first_body("CreateMicrovmAuthToken");
1428        assert_eq!(body["expirationInMinutes"], 60);
1429        assert_eq!(body["allowedPorts"][0]["port"], 9000);
1430    }
1431
1432    /// A launch request's `Debug` does not print the agent token.
1433    ///
1434    /// Asserted on the whole [`RunMicrovmRequest`] rather than on the payload alone,
1435    /// because that is the type a caller actually logs — and it keeps its derived `Debug`,
1436    /// so what is pinned here is that the derive inherits [`RunHookPayload`]'s hand-written
1437    /// one. **Falsification** — restore `#[derive(Debug)]` on [`RunHookPayload`] and the
1438    /// `SECRETTOKEN` assertion fails through the derived request `Debug`.
1439    #[test]
1440    fn a_launch_requests_debug_does_not_print_the_agent_token() {
1441        let payload = RunHookPayload::for_agent_token("SECRETTOKEN").expect("fits");
1442
1443        let rendered = format!("{payload:?}");
1444        assert!(
1445            !rendered.contains("SECRETTOKEN"),
1446            "the agent token reached a Debug string: {rendered}"
1447        );
1448        assert!(
1449            rendered.contains(&format!("{} bytes", payload.len())),
1450            "the size is what a TRAP-5 diagnosis needs: {rendered}"
1451        );
1452
1453        let request = RunMicrovmRequest::new("arn:image", payload);
1454        let rendered = format!("{request:?}");
1455        assert!(rendered.contains("arn:image"), "{rendered}");
1456        assert!(
1457            !rendered.contains("SECRETTOKEN"),
1458            "the agent token reached a Debug string through the request: {rendered}"
1459        );
1460    }
1461
1462    /// A minted token's `Debug` names its headers and never its value, so a logged
1463    /// launch does not log a credential.
1464    ///
1465    /// The mirror of `session::proxy`'s `a_proxy_token_debug_does_not_print_the_credential`,
1466    /// on the control-plane side of the conversion. **Falsification** — restore
1467    /// `#[derive(Debug)]` on [`ProxyToken`] and this fails on the `secret` assertion.
1468    #[test]
1469    fn a_proxy_token_debug_does_not_print_the_credential() {
1470        let mut map = std::collections::BTreeMap::new();
1471        map.insert(
1472            PROXY_AUTH_HEADER.to_string(),
1473            "eyJhbGciOi-secret".to_string(),
1474        );
1475        let token = ProxyToken::from_map(&map, 9000).expect("the map has the auth key");
1476
1477        let rendered = format!("{token:?}");
1478        assert!(rendered.contains(PROXY_AUTH_HEADER), "{rendered}");
1479        assert!(rendered.contains("9000"), "{rendered}");
1480        assert!(
1481            !rendered.contains("secret"),
1482            "the token value reached a Debug string: {rendered}"
1483        );
1484    }
1485
1486    /// A response whose map lacks the auth key is **retryable**, because minting happens
1487    /// inside the request retry path and a throttle mid-run must not kill a healthy trial.
1488    #[test]
1489    fn a_map_missing_the_auth_key_is_a_retryable_mint_failure() {
1490        let mut map = std::collections::BTreeMap::new();
1491        map.insert("SomeOtherKey".to_string(), "value".to_string());
1492
1493        let error = ProxyToken::from_map(&map, 9000).expect_err("no auth key");
1494        assert_eq!(
1495            error.wire_kind(),
1496            Some(crate::error::WireKind::AuthTokenMint)
1497        );
1498        assert!(error.retryable(), "minting is inside the retry path");
1499        assert!(
1500            error.to_string().contains("is a map, not a string"),
1501            "{error}"
1502        );
1503    }
1504
1505    /// The token is minted for the configured port, not always 9000.
1506    #[tokio::test]
1507    async fn the_token_is_minted_for_the_configured_port() {
1508        let fake = Arc::new(FakeControlPlane::new());
1509        let plane =
1510            ControlPlane::with_transport(fake.clone(), Region::UsEast1, Arc::new(TestClock::new()))
1511                .with_port(8080)
1512                .expect("8080 is a legal port");
1513        fake.answer(
1514            "CreateMicrovmAuthToken",
1515            Answer::ok(fake::auth_token_response("t")),
1516        );
1517
1518        let token = plane.mint_auth_token("mvm-1").await.expect("mints");
1519        assert_eq!(token.port(), 8080);
1520        assert_eq!(
1521            fake.first_body("CreateMicrovmAuthToken")["allowedPorts"][0]["port"],
1522            8080
1523        );
1524    }
1525
1526    /// **TRAP-11, across a full lifecycle.** Drive create, wait, launch, wait, mint, suspend,
1527    /// resume, terminate, delete — and then assert the recorder saw **zero** shell-auth calls
1528    /// and **zero** `SHELL_INGRESS` anywhere.
1529    ///
1530    /// A count rather than a refusal, because the closure is an absence: there is no method
1531    /// to call and no intent to name, so there is nothing that *rejects* a shell request.
1532    /// The only honest assertion is that a client doing everything it can do never emits one.
1533    ///
1534    /// Three independent scans, because a single one would be easy to satisfy accidentally:
1535    /// the operation names (a method added later), the request paths (a route reached under a
1536    /// different operation name), and the raw request bodies (a connector value however it
1537    /// got there).
1538    ///
1539    /// **Falsification** — add a `mint_shell_auth_token` calling
1540    /// `POST /2025-09-09/microvms/{id}/shell-auth-token` and call it here: the operation
1541    /// scan and the path scan both fail. Add `ConnectorIntent::ShellIngress` and request it:
1542    /// the body scan fails.
1543    #[tokio::test]
1544    async fn a_full_lifecycle_never_calls_shell_auth_or_requests_shell_ingress() {
1545        let (plane, fake, _) = planted();
1546        fake.answer(
1547            "CreateMicrovmImage",
1548            Answer::created(fake::create_image_response("img")),
1549        )
1550        .answer(
1551            "GetMicrovmImage",
1552            Answer::ok(fake::get_image_response("img", "CREATED")),
1553        )
1554        .answer(
1555            "RunMicrovm",
1556            Answer::ok(fake::microvm_response("PENDING", None)),
1557        )
1558        .answer(
1559            "GetMicrovm",
1560            Answer::ok(fake::microvm_response("RUNNING", None)),
1561        )
1562        .answer(
1563            "CreateMicrovmAuthToken",
1564            Answer::ok(fake::auth_token_response("proxy-token")),
1565        )
1566        .answer("SuspendMicrovm", Answer::ok(fake::empty_response()))
1567        .answer("ResumeMicrovm", Answer::ok(fake::empty_response()))
1568        .answer("TerminateMicrovm", Answer::ok(fake::empty_response()))
1569        .answer(
1570            "ListMicrovmImageVersions",
1571            Answer::ok(fake::list_versions_response("1")),
1572        )
1573        .answer(
1574            "DeleteMicrovmImage",
1575            Answer::ok(fake::delete_image_response()),
1576        );
1577
1578        // Everything this client can do, in the order a real run does it.
1579        let request = super::super::CreateImageRequest::new(
1580            "img",
1581            b"binary".to_vec(),
1582            "s3://bucket/img.zip",
1583            "arn:aws:iam::123456789012:role/build",
1584        );
1585        let image = plane.create_image(request).await.expect("creates");
1586        plane
1587            .wait_for_image(&image.identifier, image.size, WaitOpts::default())
1588            .await
1589            .expect("becomes usable");
1590
1591        let payload = RunHookPayload::for_agent_token("agent-token").expect("fits");
1592        let vm = plane
1593            .run_microvm(
1594                super::super::RunMicrovmRequest::new(&image.identifier, payload).with_egress(),
1595            )
1596            .await
1597            .expect("launches");
1598        plane
1599            .wait_for_running(&vm.id, WaitOpts::for_launch())
1600            .await
1601            .expect("reaches RUNNING");
1602        plane.mint_auth_token(&vm.id).await.expect("mints");
1603        plane.suspend(&vm.id).await.expect("suspends");
1604        plane.resume(&vm.id).await.expect("resumes");
1605        plane.terminate(&vm.id).await.expect("terminates");
1606        plane
1607            .delete_image(&image.identifier, 1, std::time::Duration::from_secs(1))
1608            .await;
1609
1610        // The lifecycle really ran, so the zero counts below are about absence rather than
1611        // about nothing having happened.
1612        let operations = fake.operations();
1613        assert!(
1614            operations.len() >= 10,
1615            "the lifecycle must actually have run: {operations:?}"
1616        );
1617        assert!(operations.contains(&"CreateMicrovmAuthToken"));
1618
1619        assert_eq!(
1620            fake.call_count("CreateMicrovmShellAuthToken"),
1621            0,
1622            "shell auth gates a debug console, not programmatic exec: {operations:?}"
1623        );
1624        for operation in &operations {
1625            assert!(
1626                !operation.contains("Shell"),
1627                "no operation may be a shell one: {operation}"
1628            );
1629        }
1630        for path in fake.paths() {
1631            assert!(
1632                !path.contains("shell"),
1633                "no request may reach a shell route: {path}"
1634            );
1635        }
1636        for body in fake.bodies_as_text() {
1637            assert!(
1638                !body.contains("SHELL_INGRESS"),
1639                "no request may name the shell connector: {body}"
1640            );
1641            assert!(!body.contains("SHELL"), "{body}");
1642        }
1643    }
1644
1645    /// **Issue #23.** The fleet listing follows `nextToken`, so a fleet larger than one page
1646    /// is not silently truncated.
1647    ///
1648    /// One page is a **confidently wrong** answer rather than a missing one: the caller gets a
1649    /// list that looks complete, and a VM absent from it is a VM nothing here will terminate.
1650    ///
1651    /// The assertion is the fleet **length** and the presence of the page-two ids, which is
1652    /// what a test against the single-page code cannot satisfy.
1653    ///
1654    /// **Falsification** — run 2026-08-15. Replace the `Some(token)` arm with
1655    /// `Some(_) => return Ok(items)` and this fails with 2 VMs instead of 3, and both
1656    /// page-two `contains` assertions go red. Restored.
1657    #[tokio::test]
1658    async fn the_fleet_listing_follows_next_token_to_the_vms_on_page_two() {
1659        let (plane, fake, _) = planted();
1660        fake.answer(
1661            "ListMicrovms",
1662            Answer::ok(fake::list_microvms_page(
1663                &["mvm-page1-a", "mvm-page1-b"],
1664                Some("fleet-page-2"),
1665            )),
1666        )
1667        .answer(
1668            "ListMicrovms",
1669            Answer::ok(fake::list_microvms_page(&["mvm-page2-a"], None)),
1670        );
1671
1672        let fleet = plane.list_microvms().await.expect("lists");
1673        assert_eq!(
1674            fleet.len(),
1675            3,
1676            "a fleet spanning two pages is three VMs, not two"
1677        );
1678
1679        let ids: Vec<&str> = fleet.iter().map(|vm| vm.microvm_id.as_str()).collect();
1680        assert!(ids.contains(&"mvm-page2-a"), "page two is missing: {ids:?}");
1681        assert!(ids.contains(&"mvm-page1-a"), "{ids:?}");
1682        assert_eq!(fake.call_count("ListMicrovms"), 2, "both pages were read");
1683
1684        let paths = fake.paths();
1685        assert!(
1686            paths[1].contains("nextToken=fleet-page-2"),
1687            "the second request must carry the first page's token: {}",
1688            paths[1]
1689        );
1690        assert!(
1691            !paths[0].contains('?'),
1692            "the first request carries no cursor: {}",
1693            paths[0]
1694        );
1695    }
1696
1697    /// **Issue #25.** The `idlePolicy` the service reports is carried rather than dropped.
1698    ///
1699    /// The comment on [`ops::IdlePolicy`] used to claim `suspendedDurationSeconds` "exists
1700    /// only in the request". A live `GetMicrovm` on a RUNNING VM disagrees, which is what the
1701    /// fake's body now reflects — so this test is over a response shape that was measured
1702    /// rather than assumed.
1703    ///
1704    /// **Falsification** — drop `idle_policy` from the `From<MicrovmResponseWire>` impl and
1705    /// this does not compile; set it to `None` there and every field assertion goes red.
1706    #[tokio::test]
1707    async fn the_reported_idle_policy_is_carried_rather_than_dropped() {
1708        let (plane, fake, _) = planted();
1709        fake.answer(
1710            "GetMicrovm",
1711            Answer::ok(fake::microvm_response("RUNNING", None)),
1712        );
1713
1714        let vm = plane.get_microvm("mvm-1").await.expect("reads");
1715        let policy = vm
1716            .idle_policy
1717            .expect("GetMicrovm returns an idlePolicy, measured 2026-08-15");
1718        assert_eq!(policy.max_idle_duration_seconds, 1800);
1719        assert_eq!(
1720            policy.suspended_duration_seconds, 600,
1721            "the member the comment claimed was request-only"
1722        );
1723        assert!(!policy.auto_resume_enabled);
1724    }
1725
1726    /// A response with no `idlePolicy` still parses, and the absence is `None` rather than a
1727    /// default. The model does not mark the member required on either response shape, so an
1728    /// invented `0` would be a window nobody configured.
1729    #[tokio::test]
1730    async fn an_absent_idle_policy_is_none_rather_than_a_zero_window() {
1731        let (plane, fake, _) = planted();
1732        fake.answer(
1733            "GetMicrovm",
1734            Answer::ok(
1735                r#"{
1736                    "microvmId": "mvm-1",
1737                    "state": "RUNNING",
1738                    "endpoint": "https://e",
1739                    "imageArn": "arn:aws:lambda:us-east-1:123456789012:microvm-image:img",
1740                    "imageVersion": "1"
1741                }"#,
1742            ),
1743        );
1744
1745        let vm = plane.get_microvm("mvm-1").await.expect("parses");
1746        assert_eq!(vm.idle_policy, None);
1747    }
1748
1749    /// A transport failure is retried and then succeeds, so a connection reset on the way to
1750    /// a launch is not a failed launch.
1751    #[tokio::test]
1752    async fn a_transport_failure_is_retried_rather_than_reported() {
1753        let (plane, fake, _) = planted();
1754        fake.fail_transport("GetMicrovm", 2).answer(
1755            "GetMicrovm",
1756            Answer::ok(fake::microvm_response("RUNNING", None)),
1757        );
1758
1759        let vm = plane
1760            .get_microvm("mvm-1")
1761            .await
1762            .expect("the retry succeeds");
1763        assert_eq!(vm.state, "RUNNING");
1764        assert_eq!(
1765            fake.call_count("GetMicrovm"),
1766            3,
1767            "two failures then a success"
1768        );
1769    }
1770
1771    // ── issue #24's guards on the launch and lifecycle paths ─────────────────
1772
1773    /// **`max_idle_sec: 59` no longer reaches the wire.**
1774    ///
1775    /// The measurement issue #24 made, turned into a test. The exemption it replaces was not a
1776    /// wrong number — it was a correct fact about botocore's `VALIDATED_METADATA_ATTRS` applied to
1777    /// a client that does not use botocore, so `IdlePolicy.maxIdleDurationSeconds` was the one
1778    /// constraint in the model with a *reason* for having no guard rather than an oversight.
1779    ///
1780    /// The fake has `RunMicrovm` answered, so a missing guard shows as a launch that **succeeded**
1781    /// with 59 on the wire rather than as some other failure. That is what makes the zero-call
1782    /// assertion the load-bearing one.
1783    ///
1784    /// **Guard proof** — run 2026-08-16. Delete `require_idle_duration` from `run_microvm` and
1785    /// this fails on `expect_err`, with the fake recording `RunMicrovm` once and
1786    /// `idlePolicy.maxIdleDurationSeconds: 59` in its body.
1787    #[tokio::test]
1788    async fn an_under_minimum_idle_duration_reaches_no_control_plane_call() {
1789        for under in [0, 1, 59] {
1790            let (plane, fake, _) = planted();
1791            fake.answer(
1792                "RunMicrovm",
1793                Answer::ok(fake::microvm_response("PENDING", None)),
1794            );
1795
1796            let payload = RunHookPayload::for_agent_token("token").expect("fits");
1797            let mut request = RunMicrovmRequest::new("arn:image", payload);
1798            request.max_idle_sec = under;
1799
1800            let error = plane
1801                .run_microvm(request)
1802                .await
1803                .expect_err("the model's min is 60");
1804            assert_eq!(error.kind(), ErrorKind::InvalidArg);
1805            let message = error.to_string();
1806            assert!(message.contains("maxIdleDurationSeconds"), "{message}");
1807            assert!(message.contains(&under.to_string()), "{message}");
1808            assert_eq!(
1809                fake.calls().len(),
1810                0,
1811                "{under} was refused locally; before this guard it was serialized and sent"
1812            );
1813        }
1814
1815        // 60 exactly does launch, so the boundary is inclusive and the guard is not refusing
1816        // every idle window.
1817        let (plane, fake, _) = planted();
1818        fake.answer(
1819            "RunMicrovm",
1820            Answer::ok(fake::microvm_response("PENDING", None)),
1821        );
1822        let payload = RunHookPayload::for_agent_token("token").expect("fits");
1823        let mut request = RunMicrovmRequest::new("arn:image", payload);
1824        request.max_idle_sec = 60;
1825        plane.run_microvm(request).await.expect("60 is the minimum");
1826        assert_eq!(
1827            fake.first_body("RunMicrovm")["idlePolicy"]["maxIdleDurationSeconds"],
1828            60
1829        );
1830    }
1831
1832    /// **The launch's `executionRoleArn` and `imageIdentifier`, refused before the call.**
1833    ///
1834    /// `executionRoleArn` is filled from an infra config on every real launch — the CLI reads it
1835    /// out of `MICROVM_EXECUTION_ROLE_ARN` — so a malformed one is a `ValidationException` about a
1836    /// member the caller never typed. Naming it locally is the whole difference.
1837    ///
1838    /// **Guard proof** — run 2026-08-16. Delete `require_valid_role_arn("executionRoleArn", …)`
1839    /// and the role rows launch successfully with the bad ARN on the wire; delete
1840    /// `require_valid_identifier` and the identifier rows do.
1841    #[tokio::test]
1842    async fn a_malformed_launch_role_or_identifier_reaches_no_control_plane_call() {
1843        /// What to break, aliased for the reason clippy's `type_complexity` gives — and the same
1844        /// alias `image.rs`'s create-path table uses, so the two read alike.
1845        type Mutate = fn(&mut RunMicrovmRequest);
1846        let rows: [(&str, Mutate, &str); 6] = [
1847            (
1848                "an execution role that is a bare name",
1849                |request| {
1850                    request.execution_role_arn = Some("execution-role".to_string());
1851                },
1852                "role *name*",
1853            ),
1854            (
1855                "an execution role with eleven account digits",
1856                |request| {
1857                    request.execution_role_arn =
1858                        Some("arn:aws:iam::12345678901:role/exec".to_string());
1859                },
1860                "exactly twelve digits",
1861            ),
1862            (
1863                "an execution role that is a function ARN",
1864                |request| {
1865                    request.execution_role_arn =
1866                        Some("arn:aws:lambda:us-east-1:123456789012:function:h".to_string());
1867                },
1868                "RoleArn pattern",
1869            ),
1870            (
1871                "an empty image identifier",
1872                |request| {
1873                    request.image_identifier = String::new();
1874                },
1875                "collapses the path",
1876            ),
1877            (
1878                "a 257-character image identifier",
1879                |request| {
1880                    request.image_identifier = "a".repeat(257);
1881                },
1882                "MicrovmImageArn permits 2048",
1883            ),
1884            (
1885                "an image version with a newline",
1886                |request| {
1887                    request.image_version = Some("2.0\n".to_string());
1888                },
1889                "contains whitespace",
1890            ),
1891        ];
1892
1893        for (label, mutate, expected) in rows {
1894            let (plane, fake, _) = planted();
1895            fake.answer(
1896                "RunMicrovm",
1897                Answer::ok(fake::microvm_response("PENDING", None)),
1898            );
1899
1900            let payload = RunHookPayload::for_agent_token("token").expect("fits");
1901            let mut request = RunMicrovmRequest::new("arn:image", payload);
1902            mutate(&mut request);
1903
1904            let error = plane
1905                .run_microvm(request)
1906                .await
1907                .expect_err(&format!("{label} must be refused"));
1908            assert_eq!(error.kind(), ErrorKind::InvalidArg, "{label}");
1909            assert!(
1910                error.to_string().contains(expected),
1911                "{label}: wanted {expected:?}, got {error}"
1912            );
1913            assert_eq!(fake.calls().len(), 0, "{label}");
1914        }
1915
1916        // The control case: a launch carrying a real execution role, a pinned version, and the
1917        // account's actual ARN shape goes through.
1918        let (plane, fake, _) = planted();
1919        fake.answer(
1920            "RunMicrovm",
1921            Answer::ok(fake::microvm_response("PENDING", None)),
1922        );
1923        let payload = RunHookPayload::for_agent_token("token").expect("fits");
1924        let mut request = RunMicrovmRequest::new(
1925            "arn:aws:lambda:us-east-1:392583147479:microvm-image:agentd-conformance",
1926            payload,
1927        );
1928        request.execution_role_arn =
1929            Some("arn:aws:iam::392583147479:role/bonk-sandbox-microvm-execution".to_string());
1930        request.image_version = Some("1.0".to_string());
1931        plane
1932            .run_microvm(request)
1933            .await
1934            .expect("a realistic pinned launch with a real execution role");
1935        assert_eq!(fake.call_count("RunMicrovm"), 1);
1936    }
1937
1938    /// **The `microvmIdentifier` guard on every lifecycle operation, proved by the call count.**
1939    ///
1940    /// `TerminateMicrovm` is the one worth reading twice. Its path is
1941    /// `DELETE /microvms/{microvmIdentifier}`, so an empty identifier is a `DELETE` addressed at
1942    /// the fleet collection. The service has no operation there and refuses it, but "send a delete
1943    /// at the collection and trust the service" is not a thing to leave to the service.
1944    ///
1945    /// **Guard proof** — run 2026-08-16. Delete `require_valid_identifier` from any one of the
1946    /// five and its assertion goes red with `calls: 1`.
1947    #[tokio::test]
1948    async fn no_lifecycle_operation_sends_an_empty_or_over_long_microvm_identifier() {
1949        for bad in ["", &"a".repeat(257)] {
1950            let (plane, fake, _) = planted();
1951            // Every operation answered, so a missing guard is a *successful* call rather than a
1952            // different failure.
1953            fake.answer(
1954                "GetMicrovm",
1955                Answer::ok(fake::microvm_response("RUNNING", None)),
1956            );
1957            fake.answer("SuspendMicrovm", Answer::ok("{}"));
1958            fake.answer("ResumeMicrovm", Answer::ok("{}"));
1959            fake.answer("TerminateMicrovm", Answer::ok("{}"));
1960            fake.answer(
1961                "CreateMicrovmAuthToken",
1962                Answer::ok(fake::auth_token_response("t")),
1963            );
1964
1965            plane.get_microvm(bad).await.expect_err("GetMicrovm");
1966            plane.suspend(bad).await.expect_err("SuspendMicrovm");
1967            plane.resume(bad).await.expect_err("ResumeMicrovm");
1968            plane.terminate(bad).await.expect_err("TerminateMicrovm");
1969            plane
1970                .mint_auth_token(bad)
1971                .await
1972                .expect_err("CreateMicrovmAuthToken");
1973            plane
1974                .wait_for_running(bad, WaitOpts::for_launch())
1975                .await
1976                .expect_err("GetMicrovm, in a loop");
1977
1978            assert_eq!(
1979                fake.calls().len(),
1980                0,
1981                "six operations refused {bad:?} locally. An empty identifier on the terminate \
1982                 path is a DELETE addressed at the collection."
1983            );
1984        }
1985    }
1986
1987    /// **Issue #24's `PortNumber` half: a token is never minted for port 0.**
1988    ///
1989    /// The measurement that makes this worth a guard rather than a comment is in
1990    /// [`ops::PortSpecification`]: a token whose `allowedPorts` does not cover the port a request
1991    /// names is refused with **403 `Access to port denied`**, and on the WebSocket path it is close
1992    /// code 1006 with no reason. So a token minted for port 0 authorizes nothing and fails in a
1993    /// way that reads like a bad credential rather than like an empty scope.
1994    ///
1995    /// Both ends of a range are checked as well as the single-port form, because `PortRange`'s two
1996    /// members name the same shape and a range starting at 0 is the same defect.
1997    ///
1998    /// **Guard proof** — run 2026-08-16. Delete the `for spec in ports` loop from
1999    /// `mint_auth_token_for` and all three rows mint successfully with `{"port": 0}` or
2000    /// `{"range": {"startPort": 0, …}}` on the wire.
2001    #[tokio::test]
2002    async fn no_proxy_token_is_minted_for_port_zero() {
2003        let rows: [(&str, ops::PortSpecification, &str); 3] = [
2004            (
2005                "a single port of 0",
2006                ops::PortSpecification::port(0),
2007                "allowedPorts[].port",
2008            ),
2009            (
2010                "a range starting at 0",
2011                ops::PortSpecification::range(0, 9000),
2012                "allowedPorts[].range.startPort",
2013            ),
2014            (
2015                "a range ending at 0",
2016                ops::PortSpecification::range(0, 0),
2017                "allowedPorts[].range.startPort",
2018            ),
2019        ];
2020
2021        for (label, spec, member) in rows {
2022            let (plane, fake, _) = planted();
2023            fake.answer(
2024                "CreateMicrovmAuthToken",
2025                Answer::ok(fake::auth_token_response("t")),
2026            );
2027
2028            let error = plane
2029                .mint_auth_token_for("mvm-1", &[spec])
2030                .await
2031                .expect_err(&format!("{label} must be refused"));
2032            assert_eq!(error.kind(), ErrorKind::InvalidArg, "{label}");
2033            let message = error.to_string();
2034            assert!(message.contains(member), "{label}: {message}");
2035            assert!(
2036                message.contains("authorizes nothing"),
2037                "{label}: the 403 consequence has to be in the message: {message}"
2038            );
2039            assert_eq!(fake.calls().len(), 0, "{label}");
2040        }
2041
2042        // A zero port among legal ones is still refused: the check is per-entry, not on the
2043        // first. A token minted with `[{port: 9000}, {port: 0}]` would look scoped correctly and
2044        // carry a member the service refuses the whole request over.
2045        let (plane, fake, _) = planted();
2046        fake.answer(
2047            "CreateMicrovmAuthToken",
2048            Answer::ok(fake::auth_token_response("t")),
2049        );
2050        plane
2051            .mint_auth_token_for(
2052                "mvm-1",
2053                &[
2054                    ops::PortSpecification::port(9000),
2055                    ops::PortSpecification::port(0),
2056                ],
2057            )
2058            .await
2059            .expect_err("every entry is checked, not the first");
2060        assert_eq!(fake.calls().len(), 0);
2061
2062        // And the three legal forms mint, including `allPorts` — which names no number, so there
2063        // is nothing to bound and the guard must not refuse it.
2064        for legal in [
2065            ops::PortSpecification::port(1),
2066            ops::PortSpecification::port(65_535),
2067            ops::PortSpecification::range(1, 65_535),
2068            ops::PortSpecification::all(),
2069        ] {
2070            let (plane, fake, _) = planted();
2071            fake.answer(
2072                "CreateMicrovmAuthToken",
2073                Answer::ok(fake::auth_token_response("t")),
2074            );
2075            plane
2076                .mint_auth_token_for("mvm-1", &[legal])
2077                .await
2078                .expect("a legal port specification mints");
2079            assert_eq!(fake.call_count("CreateMicrovmAuthToken"), 1);
2080        }
2081    }
2082}