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