Skip to main content

microvms_app/session/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The in-VM client: one MicroVM's control API, with the proxy auth handled for you.
3//!
4//! Talks to `agentd` through the endpoint proxy — the minted proxy token read from the
5//! auth-token header map and both proxy headers on every request (TRAP-7), minting
6//! inside the retry path below the sixty-minute ceiling (TRAP-9), and the byte-offset
7//! cursor that makes an interrupted output stream resumable.
8//!
9//! # A session holds no state worth keeping
10//!
11//! Every exec record, every file, and the bootstrap token live in the VM. So a
12//! [`Session`] rebuilt from an endpoint and an agent token reattaches to everything a
13//! previous process was doing, and [`ExecHandle`] rebuilt from an exec id addresses the
14//! same server-side exec. That is a property of the protocol rather than of this type,
15//! and it is why the exec id is caller-minted.
16//!
17//! # The wire types are the daemon's
18//!
19//! Every request body and response shape comes from the `protocol` crate, which the
20//! daemon also compiles against (ARCH-2). There is deliberately no mirror here: a field
21//! renamed on the daemon side breaks this build, which is the earliest and cheapest
22//! place a protocol change can fail.
23//!
24//! # Layout
25//!
26//! [`proxy`] is the two-header auth and the mint schedule. [`http`] is the transport
27//! seam. [`sse`] is the frame parser and the typed events. [`exec`] is the handle and
28//! the cursor-driven stream. [`files`] is file and tar transfer. The sockets (the port
29//! forwarder, the TCP tunnel, the interactive shell) and the reqwest backend are in
30//! `microvms-edges`, and `microvms_core::session` re-exports them beside these.
31
32pub mod complete;
33pub mod exec;
34pub mod files;
35pub mod http;
36pub mod keepalive;
37pub mod proxy;
38pub mod split;
39pub mod sse;
40mod upload;
41
42use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
43use std::sync::Arc;
44use std::time::Duration;
45
46pub use complete::{
47    ClientDeadline, CompletionOptions, CompletionPlan, DEFAULT_CLIENT_GRACE, KillAnswer,
48    NO_TIMEOUT_CEILING, OutputFlow, OutputSink,
49};
50pub use exec::{
51    DEFAULT_EXEC_WAIT, EndReason, ExecHandle, ExecResult, StreamEnd, StreamOptions,
52    TIMED_OUT_EXIT_CODE, exec_id_at,
53};
54pub use http::{ChunkSource, HttpBackend, HttpRequest, HttpResponse, OpenStream, SharedBackend};
55pub use keepalive::{KeepAwake, KeepAwakeEnd, KeepAwakeReport, KeepAwakeTask, RunningGate};
56pub use proxy::{
57    Clock, DEFAULT_AGENT_PORT, DEFAULT_REFRESH_AFTER, MAX_TOKEN_LIFETIME, PROXY_AUTH_HEADER,
58    PROXY_PORT_HEADER, ProxyAuth, ProxyToken, TokenMinter, WS_AUTH_SUBPROTOCOL_PREFIX,
59    WS_PORT_SUBPROTOCOL_PREFIX, WS_SUBPROTOCOL,
60};
61pub use split::{GapLog, GapPolicy, OutputGap, Split, SplitItem};
62pub use sse::{ExecEvent, Frame, SseParser};
63
64use crate::error::{Error, ErrorKind, WireKind};
65
66/// Where a tunnel or port-forward listens by default: loopback, on a port the OS picks.
67/// Loopback keeps the VM's credentials from being exposed to remote connections.
68pub const DEFAULT_BIND: SocketAddr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0));
69
70/// How long one non-streaming request may take.
71pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
72
73/// How long to wait for a daemon to report bootstrapped, once its VM is RUNNING: the wait
74/// [`crate::sandbox::Sandbox::run`] makes after the RUNNING one, and
75/// [`Session::wait_until_ready`]'s default in the bindings.
76pub const DEFAULT_BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(120);
77
78/// The old name of [`DEFAULT_BOOTSTRAP_TIMEOUT`], kept for one release.
79#[deprecated(
80    note = "renamed DEFAULT_BOOTSTRAP_TIMEOUT: `sandbox::DEFAULT_READY_TIMEOUT` shared this name \
81            for a different wait, the one for RUNNING"
82)]
83pub const DEFAULT_READY_TIMEOUT: Duration = DEFAULT_BOOTSTRAP_TIMEOUT;
84
85/// How often to re-poll health while waiting for bootstrap.
86const READY_POLL_INTERVAL: Duration = Duration::from_secs(2);
87
88/// The uid this crate's own execs that need root ask for by number.
89///
90/// Naming no user isn't asking for root: a daemon whose image sets `AGENTD_DEFAULT_USER`
91/// runs such a start as that user. A user the request names always wins, and an integer is
92/// what every daemon release accepts.
93pub(crate) const ROOT_UID: u32 = 0;
94
95/// The transport: a backend, the agent token, and the proxy auth every request needs.
96///
97/// Separate from [`Session`] because [`ExecHandle`] needs it and holding a whole session
98/// would make the two mutually recursive.
99pub struct Transport {
100    backend: http::SharedBackend,
101    agent_token: String,
102    /// `None` means send no proxy headers at all, which is the shape for talking to a
103    /// daemon directly — a local binary, a test server, or a VM reached over a tunnel.
104    /// Requiring a control-plane client for that case would make this library
105    /// untestable without AWS.
106    proxy: Option<Arc<ProxyAuth>>,
107    timeout: Duration,
108}
109
110impl Transport {
111    /// The proxy auth, when this transport has one.
112    pub fn proxy(&self) -> Option<&Arc<ProxyAuth>> {
113        self.proxy.as_ref()
114    }
115
116    /// Every header this request needs: both proxy headers, then the bearer.
117    ///
118    /// `token` is three-way rather than two: `Some(Some(..))` overrides the session's
119    /// token (which is how a 401 is provoked), `Some(None)` sends no `Authorization`
120    /// header at all (which is how the health and hook routes are exercised), and
121    /// `None` means the session's own token. A two-valued parameter could not express
122    /// the middle case, and `None`-means-no-header would make the common case the
123    /// dangerous one.
124    async fn headers(&self, token: Option<Option<&str>>) -> Result<Vec<(String, String)>, Error> {
125        // The mint sits here, inside the path every request takes, which is what makes
126        // it happen at all (TRAP-9). A failure is retryable and propagates as one.
127        let mut headers = match &self.proxy {
128            Some(proxy) => proxy.headers().await?,
129            None => Vec::new(),
130        };
131        let bearer = match token {
132            Some(explicit) => explicit,
133            None => Some(self.agent_token.as_str()),
134        };
135        if let Some(bearer) = bearer {
136            headers.push(("Authorization".to_string(), format!("Bearer {bearer}")));
137        }
138        Ok(headers)
139    }
140
141    /// Sends one request and returns the response whatever its status.
142    async fn request(&self, request: HttpRequest) -> Result<HttpResponse, Error> {
143        let timeout = request.timeout.unwrap_or(self.timeout);
144        let method = request.method;
145        let path = request.path.clone();
146        tokio::time::timeout(timeout, self.request_inner(request))
147            .await
148            .map_err(|_| Error::wire(WireKind::Transport,
149                format!("{method} {path} exceeded its request timeout (including proxy authentication)")))?
150    }
151
152    async fn request_inner(&self, mut request: HttpRequest) -> Result<HttpResponse, Error> {
153        // Prepend, never replace: the caller's headers carry the content type
154        // (exec start is application/json, file upload is octet-stream), and
155        // replacing the vec silently stripped them — the daemon answered 400
156        // "body is not a valid start request" on the first live run while every
157        // fake-backed test stayed green, because the fakes parse bodies without
158        // reading content-type. The replacement was also what stripped the
159        // token-intent marker, so keeping the caller's headers means dropping
160        // that one explicitly — it is not a real header and must not leave.
161        let mut headers = self.headers(request_token(&request)).await?;
162        request.headers.retain(|(name, _)| name != TOKEN_INTENT);
163        headers.append(&mut request.headers);
164        request.headers = headers;
165        if request.timeout.is_none() {
166            request.timeout = Some(self.timeout);
167        }
168        self.backend.send(request).await
169    }
170
171    /// [`Self::request`], failing with the typed error for any non-2xx.
172    async fn send(&self, request: HttpRequest) -> Result<HttpResponse, Error> {
173        let (method, path) = (request.method, request.path.clone());
174        let response = self.request(request).await?;
175        response.error_for_status(method, &path)?;
176        Ok(response)
177    }
178
179    /// [`Self::send`], deserializing the body.
180    async fn send_json<T: serde::de::DeserializeOwned>(
181        &self,
182        request: HttpRequest,
183    ) -> Result<T, Error> {
184        let (method, path) = (request.method, request.path.clone());
185        let response = self.send(request).await?;
186        serde_json::from_slice(&response.body).map_err(|err| {
187            // A body the client cannot read is the daemon and the client disagreeing
188            // about a shape, which is what the shared protocol crate exists to prevent —
189            // so reaching here means a version mismatch rather than a bad request.
190            Error::wire(
191                WireKind::ProtocolError,
192                format!(
193                    "{method} {path} answered a body this client cannot read: {err}; \
194                     check /v1/schema for the daemon's protocol version"
195                ),
196            )
197            .with_source(err)
198        })
199    }
200}
201
202/// A marker header naming which bearer a request wants.
203///
204/// Carried on the request rather than passed alongside it so [`Transport::request`] has
205/// one parameter instead of two that can be transposed. Stripped before the request
206/// leaves — the name is not a real header and the daemon would ignore it, but sending
207/// it would leak the choice onto the wire.
208const TOKEN_INTENT: &str = "x-microvms-core-token-intent";
209
210/// The value meaning "no Authorization header at all".
211const TOKEN_INTENT_NONE: &str = "none";
212
213fn request_token(request: &HttpRequest) -> Option<Option<&str>> {
214    match request.header(TOKEN_INTENT) {
215        None => None,
216        Some(TOKEN_INTENT_NONE) => Some(None),
217        Some(explicit) => Some(Some(explicit)),
218    }
219}
220
221/// Marks a request as unauthenticated.
222fn unauthenticated(mut request: HttpRequest) -> HttpRequest {
223    request
224        .headers
225        .push((TOKEN_INTENT.to_string(), TOKEN_INTENT_NONE.to_string()));
226    request
227}
228
229/// Measurements observed by this client's launch, in Unix seconds for timestamps.
230/// Absent timestamps mean that phase has not been observed by this client.
231#[derive(Clone, Copy, Debug, Default, PartialEq)]
232pub struct LaunchMetrics {
233    pub attempt_count: u32,
234    pub backoff_seconds: f64,
235    pub running_at: Option<f64>,
236    pub ready_at: Option<f64>,
237}
238
239/// The control API of one running MicroVM.
240///
241/// `Clone` shares the transport, including the cached proxy token, so a clone moved into a
242/// background task (a keepalive) sees the same refresh a resume triggers.
243#[derive(Clone)]
244pub struct Session {
245    transport: Arc<Transport>,
246    pub(crate) launch_metrics: Option<Arc<std::sync::Mutex<LaunchMetrics>>>,
247    upload_identity: Arc<tokio::sync::OnceCell<(u32, u32)>>,
248    endpoint: String,
249    port: u16,
250    egress_posture: crate::control::EgressPosture,
251    /// The wall time exec ids are minted from, and the clock the default proxy auth refreshes
252    /// on.
253    clock: Arc<dyn Clock>,
254}
255
256impl Session {
257    /// Launch observations, or `None` when this session did not originate from a launch.
258    pub fn launch_metrics(&self) -> Option<LaunchMetrics> {
259        self.launch_metrics.as_ref().map(|metrics| {
260            *metrics
261                .lock()
262                .unwrap_or_else(std::sync::PoisonError::into_inner)
263        })
264    }
265
266    /// The bearer this session authenticates with.
267    ///
268    /// Public because a caller who launched with a minted token needs to read it back
269    /// to reattach later (the Python oracle's `Session.agent_token` property, and what
270    /// the CLI's run envelope publishes for `microvm exec --agent-token`). The value is
271    /// still kept out of `Debug` — readable on purpose is different from printed by
272    /// accident.
273    pub fn agent_token(&self) -> &str {
274        &self.transport.agent_token
275    }
276
277    /// A builder, for the cases that need a port, a timeout, or a custom backend.
278    ///
279    /// `Session::connect`, `Session::attach` and `Session::direct` are in
280    /// `microvms_core::prelude`, because they build the production HTTP backend (and `attach` a
281    /// signed control plane), which is I/O this module leaves to its caller.
282    pub fn builder(endpoint: impl Into<String>, agent_token: impl Into<String>) -> SessionBuilder {
283        SessionBuilder {
284            endpoint: endpoint.into(),
285            agent_token: agent_token.into(),
286            minter: None,
287            backend: None,
288            port: DEFAULT_AGENT_PORT,
289            timeout: DEFAULT_REQUEST_TIMEOUT,
290            proxy: None,
291            egress_posture: crate::control::EgressPosture::default(),
292            clock: None,
293        }
294    }
295
296    /// The endpoint this session addresses.
297    pub fn endpoint(&self) -> &str {
298        &self.endpoint
299    }
300
301    /// A fresh exec id, minted from this session's clock.
302    pub(crate) fn mint_exec_id(&self) -> String {
303        exec_id_at(self.clock.unix_now())
304    }
305
306    /// The egress posture of the launch this session addresses (BIND-12).
307    ///
308    /// The value the CLI envelope's `egressPosture` reports for the same launch options, set
309    /// by [`crate::sandbox::Sandbox::run`] from [`crate::control::egress_posture_for`]. A
310    /// session that does not hold its launch options (attached, direct, or adopted) reports
311    /// `unsealed`, the weakest true claim. Never `sealed`: see
312    /// [`crate::control::egress_posture_for`] for the evidence that needs.
313    pub fn egress_posture(&self) -> crate::control::EgressPosture {
314        self.egress_posture
315    }
316
317    /// The port the proxy token is scoped to.
318    pub fn port(&self) -> u16 {
319        self.port
320    }
321
322    /// The proxy auth, for a caller that needs the mint count or an invalidation.
323    pub fn proxy_auth(&self) -> Option<&Arc<ProxyAuth>> {
324        self.transport.proxy()
325    }
326
327    // -- credentials for a connection this session does not open ------------
328    //
329    // Both of the next two exist for one caller shape: something that opens its own socket
330    // to a port on this VM — a WebSocket to an in-VM bridge, an HTTP client the caller
331    // already owns — and needs the auth that would otherwise only be reachable by going
332    // through this session's own request path. They mint through [`ProxyAuth`]'s existing
333    // cache, so a consumer never reimplements TRAP-9's schedule, and neither stores what it
334    // returns.
335
336    /// Both proxy headers for `port`, minted through this session's token cache.
337    ///
338    /// The `{url, headers}` half of a port-endpoint resolution: pair these with
339    /// [`Self::endpoint`] and a caller can open its own connection with no knowledge of how
340    /// the token is minted or when it refreshes.
341    ///
342    /// **Empty for a direct session**, which is the correct answer rather than a missing one:
343    /// a daemon reached directly takes no proxy headers, so an empty set is exactly what its
344    /// requests carry today (see [`Transport::proxy`]).
345    ///
346    /// The auth value is a bearer credential and this returns it. Nothing in this crate logs
347    /// or stores the result.
348    pub async fn connect_headers(&self, port: u16) -> Result<Vec<(String, String)>, Error> {
349        match self.transport.proxy() {
350            Some(proxy) => {
351                tokio::time::timeout(self.transport.timeout, proxy.headers_for_port(port))
352                    .await
353                    .map_err(|_| {
354                        Error::wire(
355                            WireKind::Transport,
356                            "proxy authentication request timed out",
357                        )
358                    })?
359            }
360            None => Ok(Vec::new()),
361        }
362    }
363
364    /// The three WebSocket subprotocols for `port`, minted through this session's token
365    /// cache.
366    ///
367    /// `None` for a direct session, and that is deliberately not an empty list: the
368    /// subprotocol form exists only because a request through the *endpoint proxy* cannot set
369    /// headers, so there is nothing for a directly-reached daemon to offer, and a caller
370    /// handed `["lambda-microvms"]` with no token would open a handshake that is refused for a
371    /// reason naming neither.
372    ///
373    /// See [`ProxyAuth::subprotocols`] for the wire format and for the platform's stripping
374    /// behaviour. The middle value carries the credential.
375    pub async fn connect_subprotocols(&self, port: u16) -> Result<Option<[String; 3]>, Error> {
376        match self.transport.proxy() {
377            Some(proxy) => Ok(Some(
378                tokio::time::timeout(self.transport.timeout, proxy.subprotocols(port))
379                    .await
380                    .map_err(|_| {
381                        Error::wire(
382                            WireKind::Transport,
383                            "proxy authentication request timed out",
384                        )
385                    })??,
386            )),
387            None => Ok(None),
388        }
389    }
390
391    /// Points the session at a new endpoint and drops the cached proxy token (STATE-8).
392    ///
393    /// The measured behaviour is that the endpoint URL does not change across
394    /// suspend/resume, so this is usually a no-op on the URL — but the token drop is
395    /// not: a token minted against the pre-suspend instance may no longer validate, and
396    /// that rejection reads exactly like a dead daemon.
397    ///
398    /// Takes `&mut self` because it changes where the session points, which is not
399    /// something a shared reference should be able to do mid-request. The invalidation
400    /// itself is `&self` on [`ProxyAuth`], so a resume path holding only the auth can
401    /// still drop the token.
402    pub fn rebind(&mut self, endpoint: String) {
403        self.endpoint = endpoint;
404        if let Some(proxy) = self.transport.proxy() {
405            proxy.invalidate();
406        }
407    }
408
409    // -- health ------------------------------------------------------------
410
411    /// Unauthenticated liveness. `bootstrapped` is the useful field.
412    ///
413    /// Reachable through the endpoint at all implies bootstrapped in practice — the
414    /// platform forwards no external traffic until the run hook returns 200 — but a
415    /// caller inside the VM, or one talking to the daemon directly, can observe the
416    /// pre-bootstrap state.
417    pub async fn health(&self) -> Result<protocol::health::Health, Error> {
418        self.transport
419            .send_json(unauthenticated(HttpRequest::new("GET", "/v1/health")))
420            .await
421    }
422
423    /// Discover image configuration without changing the published health response.
424    ///
425    /// `None` means the daemon predates discovery (HTTP 404). A present response
426    /// with an explicit null `default_user` means the historical daemon/root default. Authentication,
427    /// transport and malformed response errors remain errors, never guessed defaults.
428    pub async fn discovery(&self) -> Result<Option<protocol::discovery::Discovery>, Error> {
429        let response = self
430            .transport
431            .request(HttpRequest::new("GET", "/v1/discovery"))
432            .await?;
433        if response.status == 404 {
434            return Ok(None);
435        }
436        response.error_for_status("GET", "/v1/discovery")?;
437        serde_json::from_slice(&response.body)
438            .map(Some)
439            .map_err(|err| {
440                Error::wire(
441                    WireKind::ProtocolError,
442                    format!("GET /v1/discovery answered an invalid response: {err}"),
443                )
444                .with_source(err)
445            })
446    }
447
448    /// Keeps the VM awake by polling health until `stop` resolves or the policy ends it.
449    ///
450    /// See [`keepalive`]: idleness is inbound traffic only, so this is the supported way to
451    /// hold a VM up while an exec works without client traffic.
452    pub async fn keep_awake(
453        &self,
454        policy: &KeepAwake,
455        stop: impl std::future::Future<Output = ()>,
456    ) -> Result<KeepAwakeReport, Error> {
457        policy
458            .run(|| async { self.health().await.map(Some) }, stop)
459            .await
460    }
461
462    /// Polls health until the daemon reports bootstrapped.
463    ///
464    /// Connection errors are expected here rather than exceptional: a VM that has just
465    /// reached RUNNING commonly refuses a connection or two before the proxy path is
466    /// wired up, and a mint failure is retryable by construction. A *fatal* error ends
467    /// the wait immediately — retrying a 401 until the deadline is one of the two
468    /// mistakes the retryable split exists to prevent.
469    pub async fn wait_until_ready(
470        &self,
471        timeout: Duration,
472    ) -> Result<protocol::health::Health, Error> {
473        let deadline = deadline_after(timeout);
474        let mut last: Option<Error> = None;
475        loop {
476            match self.health().await {
477                Ok(health) if health.bootstrapped => {
478                    if let Some(metrics) = &self.launch_metrics {
479                        metrics
480                            .lock()
481                            .unwrap_or_else(std::sync::PoisonError::into_inner)
482                            .ready_at
483                            .get_or_insert_with(|| self.clock.unix_now().as_secs_f64());
484                    }
485                    return Ok(health);
486                }
487                Ok(_) => {}
488                Err(err) if err.retryable() => last = Some(err),
489                Err(err) => return Err(err),
490            }
491            let now = tokio::time::Instant::now();
492            if now >= deadline {
493                let detail = match last {
494                    Some(err) => format!(" (last error: {err})"),
495                    None => String::new(),
496                };
497                return Err(Error::new(
498                    ErrorKind::Timeout,
499                    format!(
500                        "the daemon was not bootstrapped within {}s{detail}",
501                        timeout.as_secs()
502                    ),
503                ));
504            }
505            tokio::time::sleep(READY_POLL_INTERVAL.min(deadline - now)).await;
506        }
507    }
508
509    // -- exec --------------------------------------------------------------
510
511    /// Starts a command and returns its handle. Does not wait.
512    ///
513    /// The `exec_id` on the request is the idempotency key. The daemon returns success
514    /// for a known id without spawning a second child, so a caller whose retry must be
515    /// safe across its own restart supplies a stable one.
516    ///
517    /// `poweroff_on_exit` requires the daemon's support header before starting.
518    /// It can end every workload in the VM and race delivery of this call's
519    /// acknowledgment or later output. It does not keep an idle VM awake.
520    pub async fn run(&self, req: protocol::exec::StartRequest) -> Result<ExecHandle, Error> {
521        if req.poweroff_on_exit {
522            let response = self
523                .transport
524                .send(unauthenticated(HttpRequest::new("GET", "/v1/health")))
525                .await?;
526            if response
527                .headers
528                .get(protocol::health::POWEROFF_ON_EXIT_HEADER)
529                .map(String::as_str)
530                != Some("1")
531            {
532                return Err(Error::invalid_arg(
533                    "poweroff_on_exit requires a supporting daemon with CAP_SYS_BOOT",
534                ));
535            }
536        }
537        let requested = req.exec_id.clone();
538        let mut request = HttpRequest::new("POST", "/v1/exec/start");
539        request
540            .headers
541            .push(("content-type".into(), "application/json".into()));
542        request.body = serde_json::to_vec(&req).map_err(|err| {
543            Error::invalid_arg(format!("the start request will not serialize: {err}"))
544        })?;
545        let started: protocol::exec::StartResponse = self.transport.send_json(request).await?;
546        // The daemon's id wins if it answered with one. It should always be the
547        // requested one — the request carries the key — but a handle built from what
548        // the client *asked for* rather than what the daemon *confirmed* would address
549        // nothing if that ever diverged, and every later call would 404.
550        let exec_id = if started.exec_id.is_empty() {
551            requested
552        } else {
553            started.exec_id
554        };
555        Ok(ExecHandle::new(Arc::clone(&self.transport), exec_id))
556    }
557
558    /// A handle for an exec started earlier, possibly by another process.
559    pub fn exec(&self, exec_id: impl Into<String>) -> ExecHandle {
560        ExecHandle::new(Arc::clone(&self.transport), exec_id.into())
561    }
562
563    /// Start, wait, ack. The one-shot shape, for when output is all you want.
564    pub async fn run_sync(
565        &self,
566        req: protocol::exec::StartRequest,
567        timeout: Duration,
568    ) -> Result<ExecResult, Error> {
569        self.run(req).await?.wait_and_ack(timeout).await
570    }
571
572    /// Signals an exec's whole process group. Returns whether anything was signalled.
573    pub async fn kill(&self, exec_id: &str) -> Result<bool, Error> {
574        self.exec(exec_id).kill().await
575    }
576
577    /// Process accounting: every registered exec with its group's live pids.
578    ///
579    /// The daemon reads `/proc` itself, so this works against a guest with no `ps`.
580    /// `child_exited: true` beside a non-empty `pids` is a command that finished while
581    /// something it backgrounded did not; the `exec_id` beside it is what [`Self::kill`]
582    /// takes. Bearer-guarded, unlike [`Self::health`]: the answer names every pid in the
583    /// guest, and a caller without the token has no business with it.
584    pub async fn procs(&self) -> Result<protocol::exec::ProcsResponse, Error> {
585        self.transport
586            .send_json(HttpRequest::new("GET", "/v1/procs"))
587            .await
588    }
589
590    // -- file transfer -----------------------------------------------------
591
592    /// Writes one file, creating parents. `mode` is octal as a string.
593    pub async fn upload_file(
594        &self,
595        path: &str,
596        data: &[u8],
597        mode: Option<&str>,
598    ) -> Result<(), Error> {
599        upload::upload(self, path, data, mode, false).await
600    }
601
602    /// Reads one file.
603    pub async fn download_file(&self, path: &str) -> Result<Vec<u8>, Error> {
604        files::download_file(&self.transport, path).await
605    }
606
607    /// Reads lines `start_line` through `end_line` of one file, 1-based and inclusive, sliced
608    /// by the daemon. Either bound may be absent (line 1, through EOF), and both absent is
609    /// [`Self::download_file`]'s request. Line 0 and an inverted range are refused with
610    /// `ERR_INVALID_ARG` before any request.
611    pub async fn download_file_lines(
612        &self,
613        path: &str,
614        start_line: Option<u64>,
615        end_line: Option<u64>,
616    ) -> Result<Vec<u8>, Error> {
617        files::download_file_lines(&self.transport, path, start_line, end_line).await
618    }
619
620    /// Whether a path exists, distinguishing absence from every other refusal.
621    pub async fn file_exists(&self, path: &str) -> Result<bool, Error> {
622        files::file_exists(&self.transport, path).await
623    }
624
625    /// Extracts pre-built tar bytes under `remote`.
626    pub async fn upload_tar(&self, remote: &str, archive: &[u8]) -> Result<(), Error> {
627        upload::upload(self, remote, archive, None, true).await
628    }
629
630    /// The raw tar bytes of a remote tree.
631    pub async fn download_tar(&self, remote: &str) -> Result<Vec<u8>, Error> {
632        files::download_tar(&self.transport, remote).await
633    }
634}
635
636impl std::fmt::Debug for Session {
637    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
638        // The agent token is a credential and stays out.
639        f.debug_struct("Session")
640            .field("endpoint", &self.endpoint)
641            .field("port", &self.port)
642            .field("proxy", &self.transport.proxy())
643            .finish()
644    }
645}
646
647/// Builds a [`Session`]. See [`Session::builder`].
648pub struct SessionBuilder {
649    endpoint: String,
650    agent_token: String,
651    minter: Option<Arc<dyn TokenMinter>>,
652    backend: Option<http::SharedBackend>,
653    port: u16,
654    timeout: Duration,
655    proxy: Option<Arc<ProxyAuth>>,
656    egress_posture: crate::control::EgressPosture,
657    clock: Option<Arc<dyn Clock>>,
658}
659
660impl SessionBuilder {
661    /// The egress posture of the launch this session addresses; `unsealed` when unset.
662    #[must_use]
663    pub fn with_egress_posture(mut self, posture: crate::control::EgressPosture) -> Self {
664        self.egress_posture = posture;
665        self
666    }
667
668    /// Mints proxy tokens through `minter`, with the default refresh schedule.
669    #[must_use]
670    pub fn with_minter(mut self, minter: Arc<dyn TokenMinter>) -> Self {
671        self.minter = Some(minter);
672        self
673    }
674
675    /// Uses an already-built [`ProxyAuth`], for a caller that needs a custom refresh
676    /// interval or clock. Wins over [`Self::with_minter`].
677    #[must_use]
678    pub fn with_proxy_auth(mut self, proxy: Arc<ProxyAuth>) -> Self {
679        self.proxy = Some(proxy);
680        self
681    }
682
683    /// Replaces the HTTP backend. The seam a simulated transport plugs into.
684    #[must_use]
685    pub fn with_backend(mut self, backend: http::SharedBackend) -> Self {
686        self.backend = Some(backend);
687        self
688    }
689
690    /// The port the proxy token is scoped to and the port header names.
691    #[must_use]
692    pub fn with_port(mut self, port: u16) -> Self {
693        self.port = port;
694        self
695    }
696
697    /// How long one non-streaming request may take.
698    #[must_use]
699    pub fn with_timeout(mut self, timeout: Duration) -> Self {
700        self.timeout = timeout;
701        self
702    }
703
704    /// The clock exec ids are minted from, and the one a proxy auth built from
705    /// [`Self::with_minter`] refreshes on. Required by [`Self::try_build`]; the adapters'
706    /// clock under [`Self::build_with`].
707    ///
708    /// A [`ProxyAuth`] passed to [`Self::with_proxy_auth`] keeps its own clock.
709    #[must_use]
710    pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Self {
711        self.clock = Some(clock);
712        self
713    }
714
715    /// Builds the session from what was set, taking an unset backend and clock from
716    /// `adapters`.
717    ///
718    /// A sandbox builds its session this way, with its plane's clock already set, so only the
719    /// backend comes from the adapters. `SessionBuilder::build` (`microvms_core::prelude`) is this
720    /// over the production adapters.
721    pub fn build_with(
722        mut self,
723        adapters: &dyn crate::adapters::Adapters,
724    ) -> Result<Session, Error> {
725        if self.backend.is_none() {
726            self.backend = Some(adapters.http_backend(&self.endpoint, self.timeout)?);
727        }
728        if self.clock.is_none() {
729            self.clock = Some(adapters.clock());
730        }
731        self.try_build()
732    }
733
734    /// Builds the session from exactly what was set.
735    ///
736    /// Fails without a backend or a clock: this module has no production default for either,
737    /// and a session that silently picked one would be a session a test can't pin.
738    pub fn try_build(self) -> Result<Session, Error> {
739        let (Some(backend), Some(clock)) = (self.backend, self.clock) else {
740            return Err(Error::new(
741                ErrorKind::Precondition,
742                "a session needs a backend and a clock: set both with with_backend and \
743                 with_clock, or build through the prelude's SessionBuilder::build",
744            ));
745        };
746        let proxy = match (self.proxy, self.minter) {
747            (Some(proxy), _) => Some(proxy),
748            (None, Some(minter)) => Some(Arc::new(ProxyAuth::with_clock(
749                minter,
750                self.port,
751                Arc::clone(&clock),
752            ))),
753            (None, None) => None,
754        };
755        Ok(Session {
756            launch_metrics: None,
757            upload_identity: Arc::new(tokio::sync::OnceCell::new()),
758            transport: Arc::new(Transport {
759                backend,
760                agent_token: self.agent_token,
761                proxy,
762                timeout: self.timeout,
763            }),
764            endpoint: self.endpoint,
765            port: self.port,
766            egress_posture: self.egress_posture,
767            clock,
768        })
769    }
770}
771
772/// How far out a deadline goes when the caller's own is past what the clock can hold: tokio's
773/// own `Instant::far_future`, thirty 365-day years, which no MicroVM outlives. Written out, since
774/// its exact value matters to nothing and cargo-mutants would mutate every operator of a product.
775const FAR_FUTURE: Duration = Duration::from_secs(946_080_000);
776
777/// The instant `after` from now, for a wait's deadline.
778///
779/// `Instant + Duration` panics when the sum passes the monotonic clock's range, about 9.2e18
780/// seconds on Linux and macOS, and every surface accepts a timeout up to about 1.8e19 (what a
781/// `Duration` holds). So a wait of `1e19` seconds would start the exec and then panic with no
782/// result (#268). Such a deadline is past any VM's life, so it saturates to [`FAR_FUTURE`]
783/// instead, as `tokio::time::sleep` does.
784pub(crate) fn deadline_after(after: Duration) -> tokio::time::Instant {
785    let now = tokio::time::Instant::now();
786    now.checked_add(after).unwrap_or_else(|| now + FAR_FUTURE)
787}
788
789#[cfg(any(test, feature = "test-support"))]
790#[cfg_attr(test, mutants::skip)] // a test double; see .cargo/mutants.toml
791pub(crate) mod testing {
792    //! A recording backend the session tests share.
793    //!
794    //! It is a *recorder* rather than an assertion sink: it keeps every request head
795    //! and answers from a queue the test loaded, so an assertion is written against
796    //! what actually went out. A fake that asserted internally would be a fake whose
797    //! expectations are invisible at the call site.
798
799    use std::collections::HashMap;
800    use std::sync::{Mutex, PoisonError};
801
802    use futures_util::future::BoxFuture;
803
804    use super::http::{ChunkSource, HttpBackend, HttpRequest, HttpResponse, OpenStream};
805    use super::*;
806
807    /// What a recorder should answer next.
808    pub enum Reply {
809        /// A status and a body.
810        Body(u16, Vec<u8>),
811        /// A stream: the head status, then these chunks in order.
812        Chunks(u16, Vec<Vec<u8>>),
813        /// A transport failure, i.e. the request never produced a status.
814        Cut(&'static str),
815        /// A 200 stream that delivers these chunks and then goes silent without ending, the
816        /// shape of a command that outlives its caller's deadline.
817        Stalled(Vec<Vec<u8>>),
818    }
819
820    impl Reply {
821        pub fn json(status: u16, body: impl serde::Serialize) -> Self {
822            Reply::Body(status, serde_json::to_vec(&body).expect("test body"))
823        }
824
825        pub fn ok(body: impl serde::Serialize) -> Self {
826            Reply::json(200, body)
827        }
828    }
829
830    #[derive(Default)]
831    pub struct Recorder {
832        seen: Mutex<Vec<HttpRequest>>,
833        replies: Mutex<std::collections::VecDeque<Reply>>,
834        // Keep headers separate: downstream callers can exhaustively match Reply.
835        headers: HashMap<String, String>,
836    }
837
838    impl Recorder {
839        pub fn with(replies: impl IntoIterator<Item = Reply>) -> Arc<Self> {
840            Self::with_headers(replies, [])
841        }
842
843        /// Answer every buffered or streaming response with these headers.
844        pub fn with_headers(
845            replies: impl IntoIterator<Item = Reply>,
846            headers: impl IntoIterator<Item = (String, String)>,
847        ) -> Arc<Self> {
848            Arc::new(Self {
849                seen: Mutex::new(Vec::new()),
850                replies: Mutex::new(replies.into_iter().collect()),
851                headers: headers.into_iter().collect(),
852            })
853        }
854
855        /// Every request the recorder saw, in order.
856        pub fn requests(&self) -> Vec<HttpRequest> {
857            self.seen
858                .lock()
859                .unwrap_or_else(PoisonError::into_inner)
860                .clone()
861        }
862
863        pub fn last(&self) -> HttpRequest {
864            self.requests()
865                .pop()
866                .expect("the recorder saw no request at all")
867        }
868
869        fn record(&self, request: HttpRequest) -> Reply {
870            self.seen
871                .lock()
872                .unwrap_or_else(PoisonError::into_inner)
873                .push(request.clone());
874            self.replies
875                .lock()
876                .unwrap_or_else(PoisonError::into_inner)
877                .pop_front()
878                .unwrap_or_else(|| {
879                    panic!(
880                        "the recorder ran out of replies at {} {}",
881                        request.method, request.path
882                    )
883                })
884        }
885    }
886
887    struct Queued(std::collections::VecDeque<Vec<u8>>);
888
889    impl ChunkSource for Queued {
890        fn next_chunk(&mut self) -> BoxFuture<'_, Result<Option<Vec<u8>>, Error>> {
891            Box::pin(async move { Ok(self.0.pop_front()) })
892        }
893    }
894
895    /// [`Queued`], except that running out of chunks never ends the body.
896    struct Stalling(std::collections::VecDeque<Vec<u8>>);
897
898    impl ChunkSource for Stalling {
899        fn next_chunk(&mut self) -> BoxFuture<'_, Result<Option<Vec<u8>>, Error>> {
900            Box::pin(async move {
901                match self.0.pop_front() {
902                    Some(chunk) => Ok(Some(chunk)),
903                    None => std::future::pending().await,
904                }
905            })
906        }
907    }
908
909    impl HttpBackend for Recorder {
910        fn send(&self, request: HttpRequest) -> BoxFuture<'_, Result<HttpResponse, Error>> {
911            let reply = self.record(request);
912            Box::pin(async move {
913                match reply {
914                    Reply::Body(status, body) => Ok(HttpResponse {
915                        status,
916                        headers: self.headers.clone(),
917                        body,
918                    }),
919                    Reply::Chunks(status, chunks) => Ok(HttpResponse {
920                        status,
921                        headers: self.headers.clone(),
922                        body: chunks.concat(),
923                    }),
924                    Reply::Stalled(chunks) => Ok(HttpResponse {
925                        status: 200,
926                        headers: self.headers.clone(),
927                        body: chunks.concat(),
928                    }),
929                    Reply::Cut(why) => Err(Error::wire(WireKind::Transport, why)),
930                }
931            })
932        }
933
934        fn open_stream(
935            &self,
936            request: HttpRequest,
937            _idle_timeout: Duration,
938        ) -> BoxFuture<'_, Result<OpenStream, Error>> {
939            let reply = self.record(request);
940            Box::pin(async move {
941                let (status, chunks) = match reply {
942                    Reply::Chunks(status, chunks) => (status, chunks),
943                    Reply::Body(status, body) => (status, vec![body]),
944                    Reply::Cut(why) => return Err(Error::wire(WireKind::Transport, why)),
945                    Reply::Stalled(chunks) => {
946                        let head = HttpResponse {
947                            status: 200,
948                            headers: self.headers.clone(),
949                            body: Vec::new(),
950                        };
951                        let source: Box<dyn ChunkSource> =
952                            Box::new(Stalling(chunks.into_iter().collect()));
953                        return Ok((head, source));
954                    }
955                };
956                let head = HttpResponse {
957                    status,
958                    headers: self.headers.clone(),
959                    // A failing head carries its body, as the real backend does, so the
960                    // typed error keeps the daemon's detail string.
961                    body: if (200..300).contains(&status) {
962                        Vec::new()
963                    } else {
964                        chunks.concat()
965                    },
966                };
967                let source: Box<dyn ChunkSource> = if (200..300).contains(&status) {
968                    Box::new(Queued(chunks.into_iter().collect()))
969                } else {
970                    Box::new(Queued(std::collections::VecDeque::new()))
971                };
972                Ok((head, source))
973            })
974        }
975    }
976
977    /// A session over a recorder, with proxy auth wired to a counting minter.
978    pub fn session_with(
979        recorder: Arc<Recorder>,
980    ) -> (
981        Session,
982        Arc<ProxyAuth>,
983        Arc<crate::clock::testing::TestClock>,
984    ) {
985        let clock = Arc::new(crate::clock::testing::TestClock::default());
986        let auth = Arc::new(
987            ProxyAuth::with_refresh_after(
988                Arc::new(proxy::testing::CountingMinter::default()),
989                DEFAULT_AGENT_PORT,
990                DEFAULT_REFRESH_AFTER,
991                Arc::clone(&clock) as Arc<dyn Clock>,
992            )
993            .expect("the default interval is below the ceiling"),
994        );
995        let session = Session::builder("https://vm.example", "agent-token-abcdef")
996            .with_backend(recorder)
997            .with_proxy_auth(Arc::clone(&auth))
998            .with_clock(Arc::clone(&clock) as Arc<dyn Clock>)
999            .try_build()
1000            .expect("builds");
1001        // Existing transfer tests model a daemon with the historical root default.
1002        // The ownership tests exercise the identity probe with an empty cache.
1003        session
1004            .upload_identity
1005            .set((0, 0))
1006            .expect("empty identity cache");
1007        (session, auth, clock)
1008    }
1009
1010    pub fn health_body(bootstrapped: bool) -> serde_json::Value {
1011        serde_json::json!({
1012            "version": "0.1.0",
1013            "bootstrapped": bootstrapped,
1014            "disk": null,
1015            "identity_degraded": false,
1016            "identity_repaired": true,
1017        })
1018    }
1019
1020    /// A daemon that answers every `GET /v1/health` bootstrapped and refuses anything else,
1021    /// counting the polls.
1022    ///
1023    /// The backend a test gives a `Sandbox` it launches through a scripted control plane: a
1024    /// launch waits for the daemon to answer (#254), and the test adapters' own backend reaches
1025    /// nothing. Anything but health fails as a transport error naming the request, so a test
1026    /// that needs a real exchange scripts a [`Recorder`] instead.
1027    #[derive(Default)]
1028    pub struct HealthyDaemon {
1029        polls: std::sync::atomic::AtomicUsize,
1030    }
1031
1032    impl HealthyDaemon {
1033        pub fn new() -> Arc<Self> {
1034            Arc::new(Self::default())
1035        }
1036
1037        /// How many health polls it answered.
1038        pub fn polls(&self) -> usize {
1039            self.polls.load(std::sync::atomic::Ordering::SeqCst)
1040        }
1041
1042        fn refuse(request: &HttpRequest) -> Error {
1043            Error::wire(
1044                WireKind::Transport,
1045                format!(
1046                    "{} {} reached a test daemon that answers only health",
1047                    request.method, request.path
1048                ),
1049            )
1050        }
1051    }
1052
1053    impl HttpBackend for HealthyDaemon {
1054        fn send(&self, request: HttpRequest) -> BoxFuture<'_, Result<HttpResponse, Error>> {
1055            let answer = if request.method == "GET" && request.path == "/v1/health" {
1056                self.polls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1057                Ok(HttpResponse {
1058                    status: 200,
1059                    headers: HashMap::new(),
1060                    body: health_body(true).to_string().into_bytes(),
1061                })
1062            } else {
1063                Err(Self::refuse(&request))
1064            };
1065            Box::pin(async move { answer })
1066        }
1067
1068        fn open_stream(
1069            &self,
1070            request: HttpRequest,
1071            _idle_timeout: Duration,
1072        ) -> BoxFuture<'_, Result<OpenStream, Error>> {
1073            let refused = Self::refuse(&request);
1074            Box::pin(async move { Err(refused) })
1075        }
1076    }
1077}
1078
1079#[cfg(test)]
1080mod tests {
1081    use super::testing::{Recorder, Reply, health_body, session_with};
1082    use super::*;
1083    use crate::session::proxy::testing::CountingMinter;
1084
1085    fn start_request(exec_id: &str) -> protocol::exec::StartRequest {
1086        protocol::exec::StartRequest::new(exec_id, vec!["/bin/true".to_string()])
1087    }
1088
1089    fn header(request: &HttpRequest, name: &str) -> Option<String> {
1090        request.header(name).map(str::to_string)
1091    }
1092
1093    /// One value out of a header pair list, matched as HTTP matches names.
1094    fn header_of(headers: &[(String, String)], name: &str) -> Option<String> {
1095        headers
1096            .iter()
1097            .find(|(key, _)| key.eq_ignore_ascii_case(name))
1098            .map(|(_, value)| value.clone())
1099    }
1100
1101    /// `try_build` refuses a builder with no backend or no clock rather than picking one:
1102    /// this module has no production default, and a silent one would be a session a test
1103    /// can't pin.
1104    ///
1105    /// **Falsification**: default the clock to a `TestClock` in `try_build` and the
1106    /// clockless builder below builds.
1107    #[test]
1108    fn try_build_needs_a_backend_and_a_clock() {
1109        let clock = Arc::new(crate::clock::testing::TestClock::new()) as Arc<dyn Clock>;
1110        let no_backend = Session::builder("https://vm.example", "token")
1111            .with_clock(Arc::clone(&clock))
1112            .try_build()
1113            .expect_err("no backend");
1114        assert_eq!(no_backend.kind(), ErrorKind::Precondition);
1115        let no_clock = Session::builder("https://vm.example", "token")
1116            .with_backend(Recorder::with([]))
1117            .try_build()
1118            .expect_err("no clock");
1119        assert_eq!(no_clock.kind(), ErrorKind::Precondition);
1120        Session::builder("https://vm.example", "token")
1121            .with_backend(Recorder::with([]))
1122            .with_clock(clock)
1123            .try_build()
1124            .expect("both set");
1125    }
1126
1127    /// `build_with` fills only what's unset: a backend the caller set wins over the
1128    /// adapters' one.
1129    ///
1130    /// **Falsification**: take the adapters' backend unconditionally and the request below
1131    /// goes to the inert test backend, which refuses it.
1132    #[tokio::test]
1133    async fn build_with_keeps_the_callers_backend() {
1134        let recorder = Recorder::with([Reply::ok(health_body(true))]);
1135        let session = Session::builder("http://127.0.0.1:9000", "token")
1136            .with_backend(Arc::clone(&recorder) as http::SharedBackend)
1137            .build_with(&crate::testing::TestAdapters::new())
1138            .expect("builds");
1139        session.health().await.expect("the recorder answered");
1140        assert_eq!(recorder.requests().len(), 1);
1141    }
1142
1143    #[tokio::test(start_paused = true)]
1144    async fn request_timeout_also_bounds_a_blocked_proxy_mint() {
1145        struct BlockedMinter;
1146        impl TokenMinter for BlockedMinter {
1147            fn mint(&self) -> futures_util::future::BoxFuture<'_, Result<ProxyToken, Error>> {
1148                Box::pin(std::future::pending())
1149            }
1150        }
1151        let backend = Recorder::with([]);
1152        let session = Session::builder("http://127.0.0.1:9", "private")
1153            .with_backend(backend.clone())
1154            .with_minter(Arc::new(BlockedMinter))
1155            .with_timeout(Duration::from_secs(3))
1156            .build_with(&crate::testing::TestAdapters::new())
1157            .expect("session");
1158        let result = tokio::time::timeout(Duration::from_secs(4), session.health())
1159            .await
1160            .expect("the request budget must include a stalled proxy mint")
1161            .expect_err("mint never completed");
1162        assert_eq!(result.wire_kind(), Some(WireKind::Transport));
1163        assert!(result.retryable());
1164        let result = tokio::time::timeout(Duration::from_secs(4), session.connect_headers(9000))
1165            .await
1166            .expect("external transfers must bound authentication too")
1167            .expect_err("mint remains blocked");
1168        assert!(result.retryable());
1169        let result =
1170            tokio::time::timeout(Duration::from_secs(4), session.connect_subprotocols(9000))
1171                .await
1172                .expect("external sockets must bound authentication too")
1173                .expect_err("mint remains blocked");
1174        assert!(result.retryable());
1175        assert!(
1176            backend.requests().is_empty(),
1177            "no request could authenticate"
1178        );
1179    }
1180
1181    /// The live-run regression: `Transport::request` REPLACED the header vec with
1182    /// the auth headers, stripping the content type the caller set — the daemon
1183    /// answered 400 "body is not a valid start request" against real axum while
1184    /// every fake stayed green (fakes parse bodies without reading content-type).
1185    ///
1186    /// **Guard proof.** Change the prepend back to `request.headers = auth_headers`
1187    /// and this test is red on the content-type assertion.
1188    #[tokio::test]
1189    async fn a_callers_content_type_survives_the_auth_header_injection() {
1190        let recorder = Recorder::with([Reply::ok(
1191            serde_json::json!({"exec_id":"e1","phase":"running"}),
1192        )]);
1193        let (session, _, _) = session_with(Arc::clone(&recorder));
1194
1195        session.run(start_request("e1")).await.expect("start");
1196
1197        let seen = recorder.requests();
1198        assert_eq!(
1199            header(&seen[0], "content-type").as_deref(),
1200            Some("application/json"),
1201            "the exec start lost its content type to the auth header injection"
1202        );
1203    }
1204
1205    /// The read-back the CLI's run envelope depends on: a caller who launched with a
1206    /// minted token can only reattach if the session hands the token back. The first
1207    /// live run published `agentToken: null` because nothing read it — the daemon then
1208    /// saw a bootstrap replay from the literal string "None".
1209    #[tokio::test]
1210    async fn the_session_hands_back_the_token_it_authenticates_with() {
1211        let recorder = Recorder::with([]);
1212        let (session, _, _) = session_with(Arc::clone(&recorder));
1213        assert_eq!(session.agent_token(), "agent-token-abcdef");
1214    }
1215
1216    /// TRAP-7's send side, asserted on a recorded request rather than on a return
1217    /// value: both proxy headers went out.
1218    ///
1219    /// **Guard proof.** Delete the `headers.push((PROXY_PORT_HEADER, ..))` branch in
1220    /// `ProxyAuth::headers_from` and this test is red on the port assertion while every
1221    /// other test in the crate stays green.
1222    #[tokio::test]
1223    async fn every_endpoint_request_carries_both_proxy_headers() {
1224        let recorder = Recorder::with([
1225            Reply::ok(health_body(true)),
1226            Reply::ok(serde_json::json!({"exec_id":"e1","phase":"running"})),
1227        ]);
1228        let (session, _, _) = session_with(Arc::clone(&recorder));
1229
1230        session.health().await.expect("health succeeds");
1231        session.run(start_request("e1")).await.expect("start");
1232
1233        let seen = recorder.requests();
1234        assert_eq!(seen.len(), 2);
1235        for request in &seen {
1236            assert_eq!(
1237                header(request, PROXY_AUTH_HEADER).as_deref(),
1238                Some(CountingMinter::value(0).as_str()),
1239                "{} {} went out without the proxy auth header",
1240                request.method,
1241                request.path
1242            );
1243            assert_eq!(
1244                header(request, PROXY_PORT_HEADER).as_deref(),
1245                Some("9000"),
1246                "{} {} went out without the proxy port header, which the proxy \
1247                 rejects as if the token were bad",
1248                request.method,
1249                request.path
1250            );
1251        }
1252    }
1253
1254    #[tokio::test]
1255    async fn discovery_distinguishes_old_peers_from_root_and_named_defaults() {
1256        let recorder = Recorder::with([
1257            Reply::Body(404, Vec::new()),
1258            Reply::ok(serde_json::json!({"default_user": null})),
1259            Reply::ok(serde_json::json!({"default_user": "1000", "future": true})),
1260            Reply::Body(401, Vec::new()),
1261            Reply::Body(503, Vec::new()),
1262            Reply::ok(serde_json::json!({"default_user": 1000})),
1263            Reply::ok(serde_json::json!({})),
1264        ]);
1265        let (session, _, _) = session_with(Arc::clone(&recorder));
1266        assert_eq!(session.discovery().await.unwrap(), None);
1267        assert_eq!(
1268            session.discovery().await.unwrap().unwrap().default_user,
1269            None
1270        );
1271        assert_eq!(
1272            session
1273                .discovery()
1274                .await
1275                .unwrap()
1276                .unwrap()
1277                .default_user
1278                .as_deref(),
1279            Some("1000")
1280        );
1281        for _ in 0..4 {
1282            assert!(session.discovery().await.is_err());
1283        }
1284        for request in recorder.requests() {
1285            assert_eq!(request.method, "GET");
1286            assert_eq!(request.path, "/v1/discovery");
1287            assert_eq!(
1288                request.header("authorization"),
1289                Some("Bearer agent-token-abcdef")
1290            );
1291        }
1292    }
1293
1294    /// Health is unauthenticated and everything else is not.
1295    ///
1296    /// Both halves matter: health carrying a bearer would still work, but it would make
1297    /// the route untestable as the unauthenticated route it is, and an exec route
1298    /// *without* one would 401.
1299    #[tokio::test]
1300    async fn health_sends_no_bearer_and_an_exec_route_does() {
1301        let recorder = Recorder::with([
1302            Reply::ok(health_body(true)),
1303            Reply::ok(serde_json::json!({"exec_id":"e1","phase":"running"})),
1304        ]);
1305        let (session, _, _) = session_with(Arc::clone(&recorder));
1306
1307        session.health().await.expect("health");
1308        let seen = recorder.requests();
1309        assert_eq!(header(&seen[0], "authorization"), None);
1310        assert_eq!(
1311            header(&seen[0], "x-microvms-core-token-intent"),
1312            None,
1313            "the internal token-intent marker must not reach the wire"
1314        );
1315
1316        session.run(start_request("e1")).await.expect("start");
1317        assert_eq!(
1318            header(&recorder.last(), "authorization").as_deref(),
1319            Some("Bearer agent-token-abcdef")
1320        );
1321    }
1322
1323    /// A keepalive poll is exactly the health route, unauthenticated but proxy-authorized:
1324    /// the proxy headers are what make it inbound traffic the idle meter counts.
1325    #[tokio::test(start_paused = true)]
1326    async fn keep_awake_polls_health_through_the_proxy_until_the_vm_is_idle() {
1327        let busy = |busy: bool| {
1328            let mut body = health_body(true);
1329            body["busy"] = serde_json::json!(busy);
1330            Reply::ok(body)
1331        };
1332        let recorder = Recorder::with([busy(true), busy(true), busy(false)]);
1333        let (session, _, _) = session_with(Arc::clone(&recorder));
1334        let report = session
1335            .clone()
1336            .keep_awake(
1337                &KeepAwake::new(None).while_busy(true),
1338                std::future::pending(),
1339            )
1340            .await
1341            .expect("keepalive");
1342        assert_eq!((report.end, report.polls), (KeepAwakeEnd::Idle, 3));
1343        let seen = recorder.requests();
1344        assert_eq!(seen.len(), 3);
1345        for request in &seen {
1346            assert_eq!(
1347                (request.method, request.path.as_str()),
1348                ("GET", "/v1/health")
1349            );
1350            assert_eq!(header(request, "authorization"), None);
1351            assert!(header(request, PROXY_AUTH_HEADER).is_some());
1352            assert!(header(request, PROXY_PORT_HEADER).is_some());
1353        }
1354    }
1355
1356    /// A session with no minter sends no proxy headers at all, which is the shape for
1357    /// a daemon reached directly.
1358    #[tokio::test]
1359    async fn a_direct_session_sends_no_proxy_headers() {
1360        let recorder = Recorder::with([Reply::ok(health_body(true))]);
1361        let session = Session::builder("http://127.0.0.1:9000", "token")
1362            .with_backend(Arc::clone(&recorder) as http::SharedBackend)
1363            .build_with(&crate::testing::TestAdapters::new())
1364            .expect("builds");
1365        assert!(session.proxy_auth().is_none());
1366
1367        session.health().await.expect("health");
1368        let request = recorder.last();
1369        assert_eq!(header(&request, PROXY_AUTH_HEADER), None);
1370        assert_eq!(header(&request, PROXY_PORT_HEADER), None);
1371    }
1372
1373    /// A mint failure surfaces as a retryable error from the request, not from
1374    /// construction — the whole point of minting in the request path.
1375    #[tokio::test]
1376    async fn a_mint_failure_surfaces_as_a_retryable_request_error() {
1377        let recorder = Recorder::with([Reply::ok(health_body(true))]);
1378        let auth = Arc::new(
1379            ProxyAuth::with_refresh_after(
1380                Arc::new(CountingMinter::failing(1)),
1381                DEFAULT_AGENT_PORT,
1382                DEFAULT_REFRESH_AFTER,
1383                Arc::new(proxy::testing::TestClock::default()),
1384            )
1385            .expect("interval accepted"),
1386        );
1387        let session = Session::builder("https://vm.example", "token")
1388            .with_backend(Arc::clone(&recorder) as http::SharedBackend)
1389            .with_proxy_auth(Arc::clone(&auth))
1390            .build_with(&crate::testing::TestAdapters::new())
1391            .expect("builds");
1392
1393        let err = session.health().await.expect_err("the mint fails");
1394        assert_eq!(err.kind(), ErrorKind::Retryable);
1395        assert_eq!(err.wire_kind(), Some(WireKind::AuthTokenMint));
1396        assert!(
1397            recorder.requests().is_empty(),
1398            "a request went out without a token"
1399        );
1400
1401        session.health().await.expect("the retry succeeds");
1402        assert_eq!(auth.mint_count(), 1);
1403    }
1404
1405    /// Every status the daemon chooses arrives as its own wire kind, from the session's
1406    /// own surface rather than from the transport in isolation.
1407    #[tokio::test]
1408    async fn a_daemon_status_arrives_as_its_typed_error() {
1409        for (status, expected) in [
1410            (401, WireKind::Unauthorized),
1411            (404, WireKind::NotFound),
1412            (409, WireKind::Conflict),
1413            (503, WireKind::NotBootstrapped),
1414        ] {
1415            let recorder = Recorder::with([Reply::Body(status, b"{\"error\":\"x\"}".to_vec())]);
1416            let (session, _, _) = session_with(recorder);
1417            let err = session.health().await.expect_err("a failing status");
1418            assert_eq!(err.wire_kind(), Some(expected), "status {status}");
1419        }
1420    }
1421
1422    /// `wait_until_ready` returns as soon as the daemon reports bootstrapped, and
1423    /// tolerates the pre-bootstrap answers before it.
1424    #[tokio::test(start_paused = true)]
1425    async fn wait_until_ready_polls_through_not_bootstrapped_and_a_dropped_connection() {
1426        let recorder = Recorder::with([
1427            Reply::Cut("connection refused"),
1428            Reply::Body(503, b"not bootstrapped".to_vec()),
1429            Reply::ok(health_body(false)),
1430            Reply::ok(health_body(true)),
1431        ]);
1432        let (session, _, _) = session_with(Arc::clone(&recorder));
1433
1434        let health = session
1435            .wait_until_ready(DEFAULT_BOOTSTRAP_TIMEOUT)
1436            .await
1437            .expect("the daemon comes up");
1438        assert!(health.bootstrapped);
1439        assert_eq!(recorder.requests().len(), 4);
1440    }
1441
1442    /// A fatal error ends the wait immediately: retrying a 401 until the deadline is
1443    /// the mistake the retryable split exists to prevent.
1444    #[tokio::test(start_paused = true)]
1445    async fn wait_until_ready_gives_up_at_once_on_a_credential_failure() {
1446        let recorder = Recorder::with([Reply::Body(401, b"wrong token".to_vec())]);
1447        let (session, _, _) = session_with(Arc::clone(&recorder));
1448
1449        let err = session
1450            .wait_until_ready(DEFAULT_BOOTSTRAP_TIMEOUT)
1451            .await
1452            .expect_err("a 401 is fatal");
1453        assert_eq!(err.kind(), ErrorKind::Credentials);
1454        assert_eq!(
1455            recorder.requests().len(),
1456            1,
1457            "a fatal error was retried, so a wrong token costs the full timeout"
1458        );
1459    }
1460
1461    /// The timeout is a client-side deadline and names the last retryable error, so a
1462    /// caller can tell "never answered" from "answered, not ready".
1463    #[tokio::test(start_paused = true)]
1464    async fn wait_until_ready_times_out_naming_the_last_retryable_error() {
1465        let recorder = Recorder::with([
1466            Reply::Cut("connection refused"),
1467            Reply::Cut("connection refused"),
1468            Reply::Cut("connection refused"),
1469        ]);
1470        let (session, _, _) = session_with(recorder);
1471
1472        let err = session
1473            .wait_until_ready(Duration::from_secs(4))
1474            .await
1475            .expect_err("the daemon never comes up");
1476        assert_eq!(err.kind(), ErrorKind::Timeout);
1477        assert!(
1478            err.to_string().contains("connection refused"),
1479            "the timeout must name what it kept hitting: {err}"
1480        );
1481    }
1482
1483    /// `rebind` drops the cached token, so the next request mints (STATE-8).
1484    ///
1485    /// The mint count is the assertion because a resumed VM keeps its endpoint URL:
1486    /// nothing about the *request* changes, and the only observable is that a fresh
1487    /// token was fetched.
1488    #[tokio::test]
1489    async fn rebind_invalidates_the_proxy_token_so_the_next_request_mints() {
1490        let recorder = Recorder::with([Reply::ok(health_body(true)), Reply::ok(health_body(true))]);
1491        let (mut session, auth, _) = session_with(Arc::clone(&recorder));
1492
1493        session.health().await.expect("health");
1494        assert_eq!(auth.mint_count(), 1);
1495
1496        session.rebind("https://vm.example".to_string());
1497        assert!(!auth.is_cached(), "rebind did not drop the token");
1498
1499        session.health().await.expect("health");
1500        assert_eq!(auth.mint_count(), 2);
1501        assert_eq!(
1502            header(&recorder.last(), PROXY_AUTH_HEADER).as_deref(),
1503            Some(CountingMinter::value(1).as_str()),
1504            "the request after a rebind carried the pre-suspend token"
1505        );
1506    }
1507
1508    /// A start request's body is the protocol crate's serialization, and the handle
1509    /// addresses the id the daemon confirmed.
1510    #[tokio::test]
1511    async fn a_start_request_serializes_through_the_protocol_crate() {
1512        let recorder = Recorder::with([Reply::ok(
1513            serde_json::json!({"exec_id":"e-confirmed","phase":"running"}),
1514        )]);
1515        let (session, _, _) = session_with(Arc::clone(&recorder));
1516
1517        let handle = session.run(start_request("e-asked")).await.expect("start");
1518        assert_eq!(
1519            handle.exec_id(),
1520            "e-confirmed",
1521            "the handle addresses the id the client asked for rather than the one the \
1522             daemon confirmed, so every later call would 404 if they diverged"
1523        );
1524
1525        let request = recorder.last();
1526        assert_eq!(request.method, "POST");
1527        assert_eq!(request.path, "/v1/exec/start");
1528        let body: serde_json::Value = serde_json::from_slice(&request.body).expect("json body");
1529        assert_eq!(body["exec_id"], "e-asked");
1530        assert_eq!(body["command"], serde_json::json!(["/bin/true"]));
1531    }
1532
1533    /// A body this client cannot read is reported as a protocol failure naming the
1534    /// schema route, not as a transport error.
1535    #[tokio::test]
1536    async fn an_unreadable_response_body_names_the_schema_route() {
1537        let recorder = Recorder::with([Reply::Body(200, b"{\"unexpected\":true}".to_vec())]);
1538        let (session, _, _) = session_with(recorder);
1539        let err = session.health().await.expect_err("the body will not parse");
1540        assert_eq!(err.wire_kind(), Some(WireKind::ProtocolError));
1541        assert!(err.to_string().contains("/v1/schema"), "{err}");
1542    }
1543
1544    // ── credentials for a caller's own connection ─────────────────────────────
1545
1546    /// **A session hands out connect credentials for a port it was not built with, off one
1547    /// token cache, minting exactly once more to cover that port.**
1548    ///
1549    /// The property that makes an external `getPortEndpoint` a few lines rather than a
1550    /// reimplementation: one health request warms the cache, the first workload-port call
1551    /// widens the token, and the two accessors then share it.
1552    ///
1553    /// This test previously required `mint_count() == 1` after the workload-port calls —
1554    /// "resolving a port endpoint burned a second control-plane call". That was the defect
1555    /// written down as a requirement: a token scoped to the agent port does not authorize 8080,
1556    /// so the credentials it praised are refused with 403 `Access to port denied` on the header
1557    /// path and close code 1006 on the WebSocket path. Measured 2026-08-15 against real AWS.
1558    /// The one-cache claim survives in the form that is true — **both** accessors answer off
1559    /// the same widened token, which is still what stops TRAP-9 becoming two schedules.
1560    ///
1561    /// **Guard proof.** Give `connect_subprotocols` its own `ProxyAuth::new(..)` — the shape a
1562    /// consumer that could not reach this session's auth would be forced into — and the mint
1563    /// count reads 3 rather than 2, and the two accessors disagree on the auth value. Verified.
1564    #[tokio::test]
1565    async fn a_session_answers_connect_credentials_for_a_port_off_its_own_token_cache() {
1566        let recorder = Recorder::with([Reply::ok(health_body(true))]);
1567        let (session, auth, _) = session_with(Arc::clone(&recorder));
1568
1569        session.health().await.expect("health warms the cache");
1570        assert_eq!(auth.mint_count(), 1);
1571
1572        let headers = session
1573            .connect_headers(8080)
1574            .await
1575            .expect("the cached token answers");
1576        let offered = session
1577            .connect_subprotocols(8080)
1578            .await
1579            .expect("the cached token answers")
1580            .expect("a proxied session offers subprotocols");
1581
1582        assert_eq!(
1583            header_of(&headers, PROXY_PORT_HEADER).as_deref(),
1584            Some("8080"),
1585            "the caller asked for 8080 and would have targeted the session's port"
1586        );
1587        // The *widened* token, which is the second one minted: the first covers 9000 only.
1588        assert_eq!(
1589            header_of(&headers, PROXY_AUTH_HEADER).as_deref(),
1590            Some(CountingMinter::value(1).as_str()),
1591            "the header pair carried the agent-port-only token, which the proxy refuses for \
1592             8080 with 403 'Access to port denied'"
1593        );
1594        assert_eq!(offered[0], proxy::WS_SUBPROTOCOL);
1595        assert_eq!(
1596            offered[1],
1597            format!(
1598                "{}{}",
1599                proxy::WS_AUTH_SUBPROTOCOL_PREFIX,
1600                CountingMinter::value(1)
1601            ),
1602            "the handshake carried a different token than the header path, so they are two \
1603             caches rather than one"
1604        );
1605        assert_eq!(offered[2], "lambda-microvms.port.8080");
1606        assert_eq!(
1607            auth.mint_count(),
1608            2,
1609            "one mint covered both the agent port and 8080, which the control plane does not \
1610             do — or the second accessor minted a third, which would be two caches"
1611        );
1612        assert!(
1613            recorder.requests().len() == 1,
1614            "building credentials sent a request to the VM: {:?}",
1615            recorder.requests()
1616        );
1617    }
1618
1619    /// **A direct session offers no subprotocols at all, and that is not an empty list.**
1620    ///
1621    /// The asymmetry between the two accessors, asserted because it is the one thing a caller
1622    /// could reasonably get backwards. Empty headers are what a direct session's requests
1623    /// really carry, so `[]` there is a true answer. A subprotocol list *without* the token
1624    /// would be a false one: the handshake would be offered, refused, and the refusal would
1625    /// name neither the token nor the port.
1626    ///
1627    /// **Guard proof.** Return `Some([WS_SUBPROTOCOL.into(), String::new(), ..])` for the
1628    /// direct case and the `is_none` assertion is red. Verified.
1629    #[tokio::test]
1630    async fn a_direct_session_offers_no_subprotocols_and_empty_headers() {
1631        let recorder = Recorder::with([]);
1632        let session = Session::builder("http://127.0.0.1:9000", "token")
1633            .with_backend(recorder as http::SharedBackend)
1634            .build_with(&crate::testing::TestAdapters::new())
1635            .expect("builds");
1636
1637        assert!(
1638            session
1639                .connect_headers(9000)
1640                .await
1641                .expect("no proxy is not a failure")
1642                .is_empty(),
1643            "a direct session's requests carry no proxy headers, so neither should this"
1644        );
1645        assert!(
1646            session
1647                .connect_subprotocols(9000)
1648                .await
1649                .expect("no proxy is not a failure")
1650                .is_none(),
1651            "a handshake with the base subprotocol and no token is refused for a reason that \
1652             names neither"
1653        );
1654    }
1655
1656    /// **A connect credential built after the refresh window carries the fresh token.**
1657    ///
1658    /// TRAP-9 through this door. A WebSocket outlives a request, so a connection opened at
1659    /// minute fifty must hold a token minted at minute fifty — and the only observable is the
1660    /// value, because a stale token and a fresh one produce identical successful handshakes
1661    /// until one is rejected.
1662    ///
1663    /// **Guard proof.** Snapshot the subprotocols at `build()` and answer from the snapshot —
1664    /// the shape a caller who resolved a port endpoint once would get — and the second value
1665    /// still reads `jwe-0`. Verified.
1666    #[tokio::test]
1667    async fn connect_credentials_re_mint_when_the_refresh_window_has_rolled_over() {
1668        let recorder = Recorder::with([]);
1669        let (session, auth, clock) = session_with(recorder);
1670
1671        let first = session
1672            .connect_subprotocols(9000)
1673            .await
1674            .expect("mints")
1675            .expect("proxied");
1676        assert!(first[1].ends_with(&CountingMinter::value(0)));
1677
1678        clock.advance(DEFAULT_REFRESH_AFTER + Duration::from_secs(1));
1679        let second = session
1680            .connect_subprotocols(9000)
1681            .await
1682            .expect("re-mints")
1683            .expect("proxied");
1684
1685        assert_eq!(auth.mint_count(), 2);
1686        assert!(
1687            second[1].ends_with(&CountingMinter::value(1)),
1688            "the handshake would carry a token past its refresh window: {}",
1689            second[1]
1690        );
1691    }
1692
1693    /// **`procs` is a bearer GET of `/v1/procs`, and its body is the typed account.**
1694    ///
1695    /// Mirrors `health` in shape and differs in the one thing that matters: the token goes
1696    /// out. The route is bearer-guarded because it names every pid in the guest, and a
1697    /// client that sent it unauthenticated would read the daemon's 503-before-bootstrap as
1698    /// a broken VM.
1699    ///
1700    /// **Guard proof.** Wrap the request in `unauthenticated(..)` the way `health` does and
1701    /// the authorization assertion is red; point it at `/v1/health` and the path one is.
1702    #[tokio::test]
1703    async fn procs_is_a_bearer_get_of_the_procs_route_with_a_typed_body() {
1704        let recorder = Recorder::with([Reply::ok(serde_json::json!({
1705            "procs": [{
1706                "exec_id": "e1",
1707                "pgid": 4242,
1708                "started_at": 1_756_500_000u64,
1709                "child_exited": true,
1710                "reap": false,
1711                "pids": [4243],
1712            }]
1713        }))]);
1714        let (session, _, _) = session_with(Arc::clone(&recorder));
1715
1716        let listed = session.procs().await.expect("procs");
1717        let request = recorder.last();
1718        assert_eq!(request.method, "GET");
1719        assert_eq!(request.path, "/v1/procs");
1720        assert_eq!(
1721            header(&request, "authorization").as_deref(),
1722            Some("Bearer agent-token-abcdef"),
1723            "the procs route is bearer-guarded; without the token it is a 503 or a 401"
1724        );
1725        assert_eq!(listed.procs.len(), 1);
1726        assert_eq!(listed.procs[0].exec_id, "e1");
1727        assert_eq!(listed.procs[0].pgid, Some(4242));
1728        assert!(listed.procs[0].child_exited);
1729        assert_eq!(listed.procs[0].pids, [4243]);
1730    }
1731
1732    /// **The reap flag rides the start body under its wire name.**
1733    ///
1734    /// The field is `#[serde(default)]` on the daemon side, so a client that dropped it
1735    /// would still be accepted — and the exec would silently keep today's leave-it-running
1736    /// behaviour. Asserted on the recorded body, not on the request struct.
1737    ///
1738    /// **Guard proof.** Add `#[serde(skip)]` to the field and this is red on the missing key.
1739    #[tokio::test]
1740    async fn a_start_request_carries_the_reap_flag_on_the_wire() {
1741        let recorder = Recorder::with([Reply::ok(
1742            serde_json::json!({"exec_id":"e1","phase":"running"}),
1743        )]);
1744        let (session, _, _) = session_with(Arc::clone(&recorder));
1745
1746        let mut request = start_request("e1");
1747        request.reap_group_on_exit = true;
1748        session.run(request).await.expect("start");
1749
1750        let body: serde_json::Value =
1751            serde_json::from_slice(&recorder.last().body).expect("a JSON start body");
1752        assert_eq!(
1753            body["reap_group_on_exit"],
1754            serde_json::Value::Bool(true),
1755            "the daemon defaults a missing flag to false, so a dropped key is a silent \
1756             behaviour change: {body}"
1757        );
1758    }
1759
1760    #[tokio::test]
1761    async fn poweroff_requires_explicit_peer_support_before_starting() {
1762        for value in [None, Some("0"), Some("true"), Some("2")] {
1763            let headers = value
1764                .map(|value| {
1765                    (
1766                        protocol::health::POWEROFF_ON_EXIT_HEADER.into(),
1767                        value.into(),
1768                    )
1769                })
1770                .into_iter();
1771            let recorder = Recorder::with_headers([Reply::Body(200, b"{}".to_vec())], headers);
1772            let (session, _, _) = session_with(Arc::clone(&recorder));
1773            let error = session
1774                .run(start_request("poweroff").with_poweroff_on_exit(true))
1775                .await
1776                .expect_err("a peer must explicitly support poweroff");
1777            assert!(error.to_string().contains("poweroff_on_exit"));
1778            let requests = recorder.requests();
1779            assert_eq!(requests.len(), 1, "an unsupported peer must not start work");
1780            assert_eq!(requests[0].method, "GET");
1781            assert_eq!(requests[0].path, "/v1/health");
1782            assert!(header(&requests[0], "authorization").is_none());
1783        }
1784    }
1785
1786    #[tokio::test]
1787    async fn poweroff_opt_in_is_gated_and_the_default_needs_no_extra_request() {
1788        for enabled in [false, true] {
1789            let mut replies = Vec::new();
1790            if enabled {
1791                replies.push(Reply::Body(200, b"{}".to_vec()));
1792            }
1793            replies.push(Reply::ok(
1794                serde_json::json!({"exec_id":"e1","phase":"running"}),
1795            ));
1796            let headers = enabled.then_some((
1797                protocol::health::POWEROFF_ON_EXIT_HEADER.to_owned(),
1798                "1".to_owned(),
1799            ));
1800            let recorder = Recorder::with_headers(replies, headers);
1801            let (session, _, _) = session_with(Arc::clone(&recorder));
1802            session
1803                .run(start_request("e1").with_poweroff_on_exit(enabled))
1804                .await
1805                .unwrap();
1806            assert_eq!(recorder.requests().len(), if enabled { 2 } else { 1 });
1807            let request = recorder.last();
1808            assert_eq!(request.method, "POST");
1809            assert_eq!(request.path, "/v1/exec/start");
1810            let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
1811            assert_eq!(
1812                body.get("poweroff_on_exit"),
1813                enabled.then_some(&serde_json::Value::Bool(true))
1814            );
1815        }
1816    }
1817
1818    /// A session's `Debug` does not print the agent token.
1819    #[test]
1820    fn a_session_debug_does_not_print_the_agent_token() {
1821        let recorder = Recorder::with([]);
1822        let session = Session::builder("https://vm.example", "super-secret-token")
1823            .with_backend(recorder as http::SharedBackend)
1824            .build_with(&crate::testing::TestAdapters::new())
1825            .expect("builds");
1826        let rendered = format!("{session:?}");
1827        assert!(rendered.contains("vm.example"), "{rendered}");
1828        assert!(
1829            !rendered.contains("super-secret"),
1830            "the agent token reached a Debug string: {rendered}"
1831        );
1832    }
1833
1834    /// A ready wait too long for the clock to add to now still polls, rather than panicking
1835    /// before the first health read (#268). Python's `wait_until_ready(timeout)` and
1836    /// TypeScript's take any figure a `Duration` holds, and `Instant + Duration` overflows past
1837    /// about 9.2e18 seconds.
1838    ///
1839    /// **Falsification**: the block below puts the unchecked `Instant::now() + timeout` back in
1840    /// `wait_until_ready`, and the wait panics.
1841    ///
1842    /// ```falsification
1843    /// id = "app-ready-deadline-overflows"
1844    /// replace = "        let deadline = deadline_after(timeout);\n"
1845    /// with = "        let deadline = tokio::time::Instant::now() + timeout;\n"
1846    /// message = "overflow when adding duration to instant"
1847    /// ```
1848    #[tokio::test(start_paused = true)]
1849    async fn a_ready_wait_longer_than_the_clock_can_hold_still_polls() {
1850        let recorder =
1851            Recorder::with([Reply::ok(health_body(false)), Reply::ok(health_body(true))]);
1852        let (session, _, _) = session_with(Arc::clone(&recorder));
1853
1854        let health = session
1855            .wait_until_ready(Duration::MAX)
1856            .await
1857            .expect("a far deadline waits for the bootstrap");
1858        assert!(health.bootstrapped);
1859        assert_eq!(
1860            recorder.requests().len(),
1861            2,
1862            "the wait polled past the first not-bootstrapped answer"
1863        );
1864    }
1865}
1866
1867#[cfg(test)]
1868mod sse_fuzz;