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