Skip to main content

microvms_core/control/
microvm.rs

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