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