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