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