Skip to main content

microvms_core/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. [`forward`] is the
29//! port-forwarder's listener and its 403-vs-502 diagnostic. [`tunnel`] is the WebSocket
30//! client that carries raw TCP to the daemon's relay.
31
32pub mod exec;
33pub mod files;
34pub mod forward;
35pub mod http;
36pub mod proxy;
37pub mod sse;
38pub mod tunnel;
39
40use std::sync::Arc;
41use std::time::Duration;
42
43pub use exec::{EndReason, ExecHandle, ExecResult, StreamEnd, StreamOptions, mint_exec_id};
44pub use forward::{
45    DEFAULT_EXCHANGE_TIMEOUT, ForwardClient, ForwardEvent, ForwardSpec, forwards_request_header,
46    refusal_explanation, upstream_url,
47};
48pub use http::{
49    ChunkSource, HttpBackend, HttpRequest, HttpResponse, OpenStream, ReqwestBackend, SharedBackend,
50};
51pub use proxy::{
52    Clock, DEFAULT_AGENT_PORT, DEFAULT_REFRESH_AFTER, MAX_TOKEN_LIFETIME, PROXY_AUTH_HEADER,
53    PROXY_PORT_HEADER, ProxyAuth, ProxyToken, TokenMinter, TokioClock, WS_AUTH_SUBPROTOCOL_PREFIX,
54    WS_PORT_SUBPROTOCOL_PREFIX, WS_SUBPROTOCOL,
55};
56pub use sse::{ExecEvent, Frame, SseParser};
57pub use tunnel::{TUNNEL_CHUNK_BYTES, TunnelEnd, explain_close, relay_connection, tunnel_url};
58
59use crate::error::{Error, ErrorKind, WireKind};
60
61/// How long one non-streaming request may take.
62pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
63
64/// How long to wait for a daemon to report bootstrapped.
65pub const DEFAULT_READY_TIMEOUT: Duration = Duration::from_secs(120);
66
67/// How often to re-poll health while waiting for bootstrap.
68const READY_POLL_INTERVAL: Duration = Duration::from_secs(2);
69
70/// The transport: a backend, the agent token, and the proxy auth every request needs.
71///
72/// Separate from [`Session`] because [`ExecHandle`] needs it and holding a whole session
73/// would make the two mutually recursive.
74pub struct Transport {
75    backend: http::SharedBackend,
76    agent_token: String,
77    /// `None` means send no proxy headers at all, which is the shape for talking to a
78    /// daemon directly — a local binary, a test server, or a VM reached over a tunnel.
79    /// Requiring a control-plane client for that case would make this library
80    /// untestable without AWS.
81    proxy: Option<Arc<ProxyAuth>>,
82    timeout: Duration,
83}
84
85impl Transport {
86    /// The proxy auth, when this transport has one.
87    pub fn proxy(&self) -> Option<&Arc<ProxyAuth>> {
88        self.proxy.as_ref()
89    }
90
91    /// Every header this request needs: both proxy headers, then the bearer.
92    ///
93    /// `token` is three-way rather than two: `Some(Some(..))` overrides the session's
94    /// token (which is how a 401 is provoked), `Some(None)` sends no `Authorization`
95    /// header at all (which is how the health and hook routes are exercised), and
96    /// `None` means the session's own token. A two-valued parameter could not express
97    /// the middle case, and `None`-means-no-header would make the common case the
98    /// dangerous one.
99    async fn headers(&self, token: Option<Option<&str>>) -> Result<Vec<(String, String)>, Error> {
100        // The mint sits here, inside the path every request takes, which is what makes
101        // it happen at all (TRAP-9). A failure is retryable and propagates as one.
102        let mut headers = match &self.proxy {
103            Some(proxy) => proxy.headers().await?,
104            None => Vec::new(),
105        };
106        let bearer = match token {
107            Some(explicit) => explicit,
108            None => Some(self.agent_token.as_str()),
109        };
110        if let Some(bearer) = bearer {
111            headers.push(("Authorization".to_string(), format!("Bearer {bearer}")));
112        }
113        Ok(headers)
114    }
115
116    /// Sends one request and returns the response whatever its status.
117    async fn request(&self, mut request: HttpRequest) -> Result<HttpResponse, Error> {
118        // Prepend, never replace: the caller's headers carry the content type
119        // (exec start is application/json, file upload is octet-stream), and
120        // replacing the vec silently stripped them — the daemon answered 400
121        // "body is not a valid start request" on the first live run while every
122        // fake-backed test stayed green, because the fakes parse bodies without
123        // reading content-type. The replacement was also what stripped the
124        // token-intent marker, so keeping the caller's headers means dropping
125        // that one explicitly — it is not a real header and must not leave.
126        let mut headers = self.headers(request_token(&request)).await?;
127        request.headers.retain(|(name, _)| name != TOKEN_INTENT);
128        headers.append(&mut request.headers);
129        request.headers = headers;
130        if request.timeout.is_none() {
131            request.timeout = Some(self.timeout);
132        }
133        self.backend.send(request).await
134    }
135
136    /// [`Self::request`], failing with the typed error for any non-2xx.
137    async fn send(&self, request: HttpRequest) -> Result<HttpResponse, Error> {
138        let (method, path) = (request.method, request.path.clone());
139        let response = self.request(request).await?;
140        response.error_for_status(method, &path)?;
141        Ok(response)
142    }
143
144    /// [`Self::send`], deserializing the body.
145    async fn send_json<T: serde::de::DeserializeOwned>(
146        &self,
147        request: HttpRequest,
148    ) -> Result<T, Error> {
149        let (method, path) = (request.method, request.path.clone());
150        let response = self.send(request).await?;
151        serde_json::from_slice(&response.body).map_err(|err| {
152            // A body the client cannot read is the daemon and the client disagreeing
153            // about a shape, which is what the shared protocol crate exists to prevent —
154            // so reaching here means a version mismatch rather than a bad request.
155            Error::wire(
156                WireKind::ProtocolError,
157                format!(
158                    "{method} {path} answered a body this client cannot read: {err}; \
159                     check /v1/schema for the daemon's protocol version"
160                ),
161            )
162            .with_source(err)
163        })
164    }
165}
166
167/// A marker header naming which bearer a request wants.
168///
169/// Carried on the request rather than passed alongside it so [`Transport::request`] has
170/// one parameter instead of two that can be transposed. Stripped before the request
171/// leaves — the name is not a real header and the daemon would ignore it, but sending
172/// it would leak the choice onto the wire.
173const TOKEN_INTENT: &str = "x-microvms-core-token-intent";
174
175/// The value meaning "no Authorization header at all".
176const TOKEN_INTENT_NONE: &str = "none";
177
178fn request_token(request: &HttpRequest) -> Option<Option<&str>> {
179    match request.header(TOKEN_INTENT) {
180        None => None,
181        Some(TOKEN_INTENT_NONE) => Some(None),
182        Some(explicit) => Some(Some(explicit)),
183    }
184}
185
186/// Marks a request as unauthenticated.
187fn unauthenticated(mut request: HttpRequest) -> HttpRequest {
188    request
189        .headers
190        .push((TOKEN_INTENT.to_string(), TOKEN_INTENT_NONE.to_string()));
191    request
192}
193
194/// The control API of one running MicroVM.
195pub struct Session {
196    transport: Arc<Transport>,
197    endpoint: String,
198    port: u16,
199}
200
201impl Session {
202    /// The bearer this session authenticates with.
203    ///
204    /// Public because a caller who launched with a minted token needs to read it back
205    /// to reattach later (the Python oracle's `Session.agent_token` property, and what
206    /// the CLI's run envelope publishes for `microvm exec --agent-token`). The value is
207    /// still kept out of `Debug` — readable on purpose is different from printed by
208    /// accident.
209    pub fn agent_token(&self) -> &str {
210        &self.transport.agent_token
211    }
212
213    /// A session against `endpoint`, minting proxy tokens through `minter`.
214    ///
215    /// Does not talk to the VM. Constructing a session is free and re-doable, and a
216    /// constructor that probed would make "do I have a session" mean "is the VM up",
217    /// which are different questions with different answers during a launch.
218    pub async fn connect(
219        endpoint: impl Into<String>,
220        agent_token: impl Into<String>,
221        minter: Arc<dyn TokenMinter>,
222    ) -> Result<Session, Error> {
223        Self::builder(endpoint, agent_token)
224            .with_minter(minter)
225            .build()
226    }
227
228    /// A session with no proxy auth, for a daemon reached directly.
229    ///
230    /// The conformance path and every local-binary test go through here. See
231    /// [`Transport::proxy`] on why this is a supported shape rather than a test-only
232    /// escape hatch.
233    pub fn direct(
234        endpoint: impl Into<String>,
235        agent_token: impl Into<String>,
236    ) -> Result<Session, Error> {
237        Self::builder(endpoint, agent_token).build()
238    }
239
240    /// A builder, for the cases that need a port, a timeout, or a custom backend.
241    pub fn builder(endpoint: impl Into<String>, agent_token: impl Into<String>) -> SessionBuilder {
242        SessionBuilder {
243            endpoint: endpoint.into(),
244            agent_token: agent_token.into(),
245            minter: None,
246            backend: None,
247            port: DEFAULT_AGENT_PORT,
248            timeout: DEFAULT_REQUEST_TIMEOUT,
249            proxy: None,
250        }
251    }
252
253    /// The endpoint this session addresses.
254    pub fn endpoint(&self) -> &str {
255        &self.endpoint
256    }
257
258    /// The port the proxy token is scoped to.
259    pub fn port(&self) -> u16 {
260        self.port
261    }
262
263    /// The proxy auth, for a caller that needs the mint count or an invalidation.
264    pub fn proxy_auth(&self) -> Option<&Arc<ProxyAuth>> {
265        self.transport.proxy()
266    }
267
268    // -- credentials for a connection this session does not open ------------
269    //
270    // Both of the next two exist for one caller shape: something that opens its own socket
271    // to a port on this VM — a WebSocket to an in-VM bridge, an HTTP client the caller
272    // already owns — and needs the auth that would otherwise only be reachable by going
273    // through this session's own request path. They mint through [`ProxyAuth`]'s existing
274    // cache, so a consumer never reimplements TRAP-9's schedule, and neither stores what it
275    // returns.
276
277    /// Both proxy headers for `port`, minted through this session's token cache.
278    ///
279    /// The `{url, headers}` half of a port-endpoint resolution: pair these with
280    /// [`Self::endpoint`] and a caller can open its own connection with no knowledge of how
281    /// the token is minted or when it refreshes.
282    ///
283    /// **Empty for a direct session**, which is the correct answer rather than a missing one:
284    /// a daemon reached directly takes no proxy headers, so an empty set is exactly what its
285    /// requests carry today (see [`Transport::proxy`]).
286    ///
287    /// The auth value is a bearer credential and this returns it. Nothing in this crate logs
288    /// or stores the result.
289    pub async fn connect_headers(&self, port: u16) -> Result<Vec<(String, String)>, Error> {
290        match self.transport.proxy() {
291            Some(proxy) => proxy.headers_for_port(port).await,
292            None => Ok(Vec::new()),
293        }
294    }
295
296    /// The three WebSocket subprotocols for `port`, minted through this session's token
297    /// cache.
298    ///
299    /// `None` for a direct session, and that is deliberately not an empty list: the
300    /// subprotocol form exists only because a request through the *endpoint proxy* cannot set
301    /// headers, so there is nothing for a directly-reached daemon to offer, and a caller
302    /// handed `["lambda-microvms"]` with no token would open a handshake that is refused for a
303    /// reason naming neither.
304    ///
305    /// See [`ProxyAuth::subprotocols`] for the wire format and for the platform's stripping
306    /// behaviour. The middle value carries the credential.
307    pub async fn connect_subprotocols(&self, port: u16) -> Result<Option<[String; 3]>, Error> {
308        match self.transport.proxy() {
309            Some(proxy) => Ok(Some(proxy.subprotocols(port).await?)),
310            None => Ok(None),
311        }
312    }
313
314    /// Points the session at a new endpoint and drops the cached proxy token (STATE-8).
315    ///
316    /// The measured behaviour is that the endpoint URL does not change across
317    /// suspend/resume, so this is usually a no-op on the URL — but the token drop is
318    /// not: a token minted against the pre-suspend instance may no longer validate, and
319    /// that rejection reads exactly like a dead daemon.
320    ///
321    /// Takes `&mut self` because it changes where the session points, which is not
322    /// something a shared reference should be able to do mid-request. The invalidation
323    /// itself is `&self` on [`ProxyAuth`], so a resume path holding only the auth can
324    /// still drop the token.
325    pub fn rebind(&mut self, endpoint: String) {
326        self.endpoint = endpoint;
327        if let Some(proxy) = self.transport.proxy() {
328            proxy.invalidate();
329        }
330    }
331
332    // -- health ------------------------------------------------------------
333
334    /// Unauthenticated liveness. `bootstrapped` is the useful field.
335    ///
336    /// Reachable through the endpoint at all implies bootstrapped in practice — the
337    /// platform forwards no external traffic until the run hook returns 200 — but a
338    /// caller inside the VM, or one talking to the daemon directly, can observe the
339    /// pre-bootstrap state.
340    pub async fn health(&self) -> Result<protocol::health::Health, Error> {
341        self.transport
342            .send_json(unauthenticated(HttpRequest::new("GET", "/v1/health")))
343            .await
344    }
345
346    /// Polls health until the daemon reports bootstrapped.
347    ///
348    /// Connection errors are expected here rather than exceptional: a VM that has just
349    /// reached RUNNING commonly refuses a connection or two before the proxy path is
350    /// wired up, and a mint failure is retryable by construction. A *fatal* error ends
351    /// the wait immediately — retrying a 401 until the deadline is one of the two
352    /// mistakes the retryable split exists to prevent.
353    pub async fn wait_until_ready(
354        &self,
355        timeout: Duration,
356    ) -> Result<protocol::health::Health, Error> {
357        let deadline = tokio::time::Instant::now() + timeout;
358        let mut last: Option<Error> = None;
359        loop {
360            match self.health().await {
361                Ok(health) if health.bootstrapped => return Ok(health),
362                Ok(_) => {}
363                Err(err) if err.retryable() => last = Some(err),
364                Err(err) => return Err(err),
365            }
366            let now = tokio::time::Instant::now();
367            if now >= deadline {
368                let detail = match last {
369                    Some(err) => format!(" (last error: {err})"),
370                    None => String::new(),
371                };
372                return Err(Error::new(
373                    ErrorKind::Timeout,
374                    format!(
375                        "the daemon was not bootstrapped within {}s{detail}",
376                        timeout.as_secs()
377                    ),
378                ));
379            }
380            tokio::time::sleep(READY_POLL_INTERVAL.min(deadline - now)).await;
381        }
382    }
383
384    // -- exec --------------------------------------------------------------
385
386    /// Starts a command and returns its handle. Does not wait.
387    ///
388    /// The `exec_id` on the request is the idempotency key. The daemon returns success
389    /// for a known id without spawning a second child, so a caller whose retry must be
390    /// safe across its own restart supplies a stable one.
391    pub async fn run(&self, req: protocol::exec::StartRequest) -> Result<ExecHandle, Error> {
392        let requested = req.exec_id.clone();
393        let mut request = HttpRequest::new("POST", "/v1/exec/start");
394        request
395            .headers
396            .push(("content-type".into(), "application/json".into()));
397        request.body = serde_json::to_vec(&req).map_err(|err| {
398            Error::invalid_arg(format!("the start request will not serialize: {err}"))
399        })?;
400        let started: protocol::exec::StartResponse = self.transport.send_json(request).await?;
401        // The daemon's id wins if it answered with one. It should always be the
402        // requested one — the request carries the key — but a handle built from what
403        // the client *asked for* rather than what the daemon *confirmed* would address
404        // nothing if that ever diverged, and every later call would 404.
405        let exec_id = if started.exec_id.is_empty() {
406            requested
407        } else {
408            started.exec_id
409        };
410        Ok(ExecHandle::new(Arc::clone(&self.transport), exec_id))
411    }
412
413    /// A handle for an exec started earlier, possibly by another process.
414    pub fn exec(&self, exec_id: impl Into<String>) -> ExecHandle {
415        ExecHandle::new(Arc::clone(&self.transport), exec_id.into())
416    }
417
418    /// Start, wait, ack. The one-shot shape, for when output is all you want.
419    pub async fn run_sync(
420        &self,
421        req: protocol::exec::StartRequest,
422        timeout: Duration,
423    ) -> Result<ExecResult, Error> {
424        self.run(req).await?.wait_and_ack(timeout).await
425    }
426
427    /// Signals an exec's whole process group. Returns whether anything was signalled.
428    pub async fn kill(&self, exec_id: &str) -> Result<bool, Error> {
429        self.exec(exec_id).kill().await
430    }
431
432    // -- file transfer -----------------------------------------------------
433
434    /// Writes one file, creating parents. `mode` is octal as a string.
435    pub async fn upload_file(
436        &self,
437        path: &str,
438        data: &[u8],
439        mode: Option<&str>,
440    ) -> Result<(), Error> {
441        files::upload_file(&self.transport, path, data, mode).await
442    }
443
444    /// Reads one file.
445    pub async fn download_file(&self, path: &str) -> Result<Vec<u8>, Error> {
446        files::download_file(&self.transport, path).await
447    }
448
449    /// Whether a path exists, distinguishing absence from every other refusal.
450    pub async fn file_exists(&self, path: &str) -> Result<bool, Error> {
451        files::file_exists(&self.transport, path).await
452    }
453
454    /// Extracts pre-built tar bytes under `remote`.
455    pub async fn upload_tar(&self, remote: &str, archive: &[u8]) -> Result<(), Error> {
456        files::upload_tar(&self.transport, remote, archive).await
457    }
458
459    /// The raw tar bytes of a remote tree.
460    pub async fn download_tar(&self, remote: &str) -> Result<Vec<u8>, Error> {
461        files::download_tar(&self.transport, remote).await
462    }
463}
464
465impl std::fmt::Debug for Session {
466    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
467        // The agent token is a credential and stays out.
468        f.debug_struct("Session")
469            .field("endpoint", &self.endpoint)
470            .field("port", &self.port)
471            .field("proxy", &self.transport.proxy())
472            .finish()
473    }
474}
475
476/// Builds a [`Session`]. See [`Session::builder`].
477pub struct SessionBuilder {
478    endpoint: String,
479    agent_token: String,
480    minter: Option<Arc<dyn TokenMinter>>,
481    backend: Option<http::SharedBackend>,
482    port: u16,
483    timeout: Duration,
484    proxy: Option<Arc<ProxyAuth>>,
485}
486
487impl SessionBuilder {
488    /// Mints proxy tokens through `minter`, with the default refresh schedule.
489    #[must_use]
490    pub fn with_minter(mut self, minter: Arc<dyn TokenMinter>) -> Self {
491        self.minter = Some(minter);
492        self
493    }
494
495    /// Uses an already-built [`ProxyAuth`], for a caller that needs a custom refresh
496    /// interval or clock. Wins over [`Self::with_minter`].
497    #[must_use]
498    pub fn with_proxy_auth(mut self, proxy: Arc<ProxyAuth>) -> Self {
499        self.proxy = Some(proxy);
500        self
501    }
502
503    /// Replaces the HTTP backend. The seam a simulated transport plugs into.
504    #[must_use]
505    pub fn with_backend(mut self, backend: http::SharedBackend) -> Self {
506        self.backend = Some(backend);
507        self
508    }
509
510    /// The port the proxy token is scoped to and the port header names.
511    #[must_use]
512    pub fn with_port(mut self, port: u16) -> Self {
513        self.port = port;
514        self
515    }
516
517    /// How long one non-streaming request may take.
518    #[must_use]
519    pub fn with_timeout(mut self, timeout: Duration) -> Self {
520        self.timeout = timeout;
521        self
522    }
523
524    /// Builds the session.
525    pub fn build(self) -> Result<Session, Error> {
526        let backend = match self.backend {
527            Some(backend) => backend,
528            None => Arc::new(ReqwestBackend::new(&self.endpoint, self.timeout)?),
529        };
530        let proxy = match (self.proxy, self.minter) {
531            (Some(proxy), _) => Some(proxy),
532            (None, Some(minter)) => Some(Arc::new(ProxyAuth::new(minter, self.port))),
533            (None, None) => None,
534        };
535        Ok(Session {
536            transport: Arc::new(Transport {
537                backend,
538                agent_token: self.agent_token,
539                proxy,
540                timeout: self.timeout,
541            }),
542            endpoint: self.endpoint,
543            port: self.port,
544        })
545    }
546}
547
548#[cfg(test)]
549pub(crate) mod testing {
550    //! A recording backend the session tests share.
551    //!
552    //! It is a *recorder* rather than an assertion sink: it keeps every request head
553    //! and answers from a queue the test loaded, so an assertion is written against
554    //! what actually went out. A fake that asserted internally would be a fake whose
555    //! expectations are invisible at the call site.
556
557    use std::collections::HashMap;
558    use std::sync::{Mutex, PoisonError};
559
560    use futures_util::future::BoxFuture;
561
562    use super::http::{ChunkSource, HttpBackend, HttpRequest, HttpResponse, OpenStream};
563    use super::*;
564
565    /// What a recorder should answer next.
566    pub(crate) enum Reply {
567        /// A status and a body.
568        Body(u16, Vec<u8>),
569        /// A stream: the head status, then these chunks in order.
570        Chunks(u16, Vec<Vec<u8>>),
571        /// A transport failure, i.e. the request never produced a status.
572        Cut(&'static str),
573    }
574
575    impl Reply {
576        pub(crate) fn json(status: u16, body: impl serde::Serialize) -> Self {
577            Reply::Body(status, serde_json::to_vec(&body).expect("test body"))
578        }
579
580        pub(crate) fn ok(body: impl serde::Serialize) -> Self {
581            Reply::json(200, body)
582        }
583    }
584
585    #[derive(Default)]
586    pub(crate) struct Recorder {
587        seen: Mutex<Vec<HttpRequest>>,
588        replies: Mutex<std::collections::VecDeque<Reply>>,
589    }
590
591    impl Recorder {
592        pub(crate) fn with(replies: impl IntoIterator<Item = Reply>) -> Arc<Self> {
593            Arc::new(Self {
594                seen: Mutex::new(Vec::new()),
595                replies: Mutex::new(replies.into_iter().collect()),
596            })
597        }
598
599        /// Every request the recorder saw, in order.
600        pub(crate) fn requests(&self) -> Vec<HttpRequest> {
601            self.seen
602                .lock()
603                .unwrap_or_else(PoisonError::into_inner)
604                .clone()
605        }
606
607        pub(crate) fn last(&self) -> HttpRequest {
608            self.requests()
609                .pop()
610                .expect("the recorder saw no request at all")
611        }
612
613        fn record(&self, request: HttpRequest) -> Reply {
614            self.seen
615                .lock()
616                .unwrap_or_else(PoisonError::into_inner)
617                .push(request.clone());
618            self.replies
619                .lock()
620                .unwrap_or_else(PoisonError::into_inner)
621                .pop_front()
622                .unwrap_or_else(|| {
623                    panic!(
624                        "the recorder ran out of replies at {} {}",
625                        request.method, request.path
626                    )
627                })
628        }
629    }
630
631    struct Queued(std::collections::VecDeque<Vec<u8>>);
632
633    impl ChunkSource for Queued {
634        fn next_chunk(&mut self) -> BoxFuture<'_, Result<Option<Vec<u8>>, Error>> {
635            Box::pin(async move { Ok(self.0.pop_front()) })
636        }
637    }
638
639    impl HttpBackend for Recorder {
640        fn send(&self, request: HttpRequest) -> BoxFuture<'_, Result<HttpResponse, Error>> {
641            let reply = self.record(request);
642            Box::pin(async move {
643                match reply {
644                    Reply::Body(status, body) => Ok(HttpResponse {
645                        status,
646                        headers: HashMap::new(),
647                        body,
648                    }),
649                    Reply::Chunks(status, chunks) => Ok(HttpResponse {
650                        status,
651                        headers: HashMap::new(),
652                        body: chunks.concat(),
653                    }),
654                    Reply::Cut(why) => Err(Error::wire(WireKind::Transport, why)),
655                }
656            })
657        }
658
659        fn open_stream(
660            &self,
661            request: HttpRequest,
662            _idle_timeout: Duration,
663        ) -> BoxFuture<'_, Result<OpenStream, Error>> {
664            let reply = self.record(request);
665            Box::pin(async move {
666                let (status, chunks) = match reply {
667                    Reply::Chunks(status, chunks) => (status, chunks),
668                    Reply::Body(status, body) => (status, vec![body]),
669                    Reply::Cut(why) => return Err(Error::wire(WireKind::Transport, why)),
670                };
671                let head = HttpResponse {
672                    status,
673                    headers: HashMap::new(),
674                    // A failing head carries its body, as the real backend does, so the
675                    // typed error keeps the daemon's detail string.
676                    body: if (200..300).contains(&status) {
677                        Vec::new()
678                    } else {
679                        chunks.concat()
680                    },
681                };
682                let source: Box<dyn ChunkSource> = if (200..300).contains(&status) {
683                    Box::new(Queued(chunks.into_iter().collect()))
684                } else {
685                    Box::new(Queued(std::collections::VecDeque::new()))
686                };
687                Ok((head, source))
688            })
689        }
690    }
691
692    /// A session over a recorder, with proxy auth wired to a counting minter.
693    pub(crate) fn session_with(
694        recorder: Arc<Recorder>,
695    ) -> (Session, Arc<ProxyAuth>, Arc<proxy::testing::ManualClock>) {
696        let clock = Arc::new(proxy::testing::ManualClock::default());
697        let auth = Arc::new(
698            ProxyAuth::with_refresh_after(
699                Arc::new(proxy::testing::CountingMinter::default()),
700                DEFAULT_AGENT_PORT,
701                DEFAULT_REFRESH_AFTER,
702                Arc::clone(&clock) as Arc<dyn Clock>,
703            )
704            .expect("the default interval is below the ceiling"),
705        );
706        let session = Session::builder("https://vm.example", "agent-token-abcdef")
707            .with_backend(recorder)
708            .with_proxy_auth(Arc::clone(&auth))
709            .build()
710            .expect("builds");
711        (session, auth, clock)
712    }
713
714    pub(crate) fn health_body(bootstrapped: bool) -> serde_json::Value {
715        serde_json::json!({
716            "version": "0.1.0",
717            "bootstrapped": bootstrapped,
718            "disk": null,
719            "identity_degraded": false,
720            "identity_repaired": true,
721        })
722    }
723}
724
725#[cfg(test)]
726mod tests {
727    use super::testing::{Recorder, Reply, health_body, session_with};
728    use super::*;
729    use crate::session::proxy::testing::CountingMinter;
730
731    fn start_request(exec_id: &str) -> protocol::exec::StartRequest {
732        protocol::exec::StartRequest {
733            exec_id: exec_id.to_string(),
734            command: vec!["/bin/true".to_string()],
735            shell: false,
736            cwd: None,
737            env: Default::default(),
738            user: None,
739            group: None,
740            timeout_sec: None,
741            stdin: false,
742        }
743    }
744
745    fn header(request: &HttpRequest, name: &str) -> Option<String> {
746        request.header(name).map(str::to_string)
747    }
748
749    /// One value out of a header pair list, matched as HTTP matches names.
750    fn header_of(headers: &[(String, String)], name: &str) -> Option<String> {
751        headers
752            .iter()
753            .find(|(key, _)| key.eq_ignore_ascii_case(name))
754            .map(|(_, value)| value.clone())
755    }
756
757    /// The live-run regression: `Transport::request` REPLACED the header vec with
758    /// the auth headers, stripping the content type the caller set — the daemon
759    /// answered 400 "body is not a valid start request" against real axum while
760    /// every fake stayed green (fakes parse bodies without reading content-type).
761    ///
762    /// **Guard proof.** Change the prepend back to `request.headers = auth_headers`
763    /// and this test is red on the content-type assertion.
764    #[tokio::test]
765    async fn a_callers_content_type_survives_the_auth_header_injection() {
766        let recorder = Recorder::with([Reply::ok(
767            serde_json::json!({"exec_id":"e1","phase":"running"}),
768        )]);
769        let (session, _, _) = session_with(Arc::clone(&recorder));
770
771        session.run(start_request("e1")).await.expect("start");
772
773        let seen = recorder.requests();
774        assert_eq!(
775            header(&seen[0], "content-type").as_deref(),
776            Some("application/json"),
777            "the exec start lost its content type to the auth header injection"
778        );
779    }
780
781    /// The read-back the CLI's run envelope depends on: a caller who launched with a
782    /// minted token can only reattach if the session hands the token back. The first
783    /// live run published `agentToken: null` because nothing read it — the daemon then
784    /// saw a bootstrap replay from the literal string "None".
785    #[tokio::test]
786    async fn the_session_hands_back_the_token_it_authenticates_with() {
787        let recorder = Recorder::with([]);
788        let (session, _, _) = session_with(Arc::clone(&recorder));
789        assert_eq!(session.agent_token(), "agent-token-abcdef");
790    }
791
792    /// TRAP-7's send side, asserted on a recorded request rather than on a return
793    /// value: both proxy headers went out.
794    ///
795    /// **Guard proof.** Delete the `headers.push((PROXY_PORT_HEADER, ..))` branch in
796    /// `ProxyAuth::headers_from` and this test is red on the port assertion while every
797    /// other test in the crate stays green.
798    #[tokio::test]
799    async fn every_endpoint_request_carries_both_proxy_headers() {
800        let recorder = Recorder::with([
801            Reply::ok(health_body(true)),
802            Reply::ok(serde_json::json!({"exec_id":"e1","phase":"running"})),
803        ]);
804        let (session, _, _) = session_with(Arc::clone(&recorder));
805
806        session.health().await.expect("health succeeds");
807        session.run(start_request("e1")).await.expect("start");
808
809        let seen = recorder.requests();
810        assert_eq!(seen.len(), 2);
811        for request in &seen {
812            assert_eq!(
813                header(request, PROXY_AUTH_HEADER).as_deref(),
814                Some(CountingMinter::value(0).as_str()),
815                "{} {} went out without the proxy auth header",
816                request.method,
817                request.path
818            );
819            assert_eq!(
820                header(request, PROXY_PORT_HEADER).as_deref(),
821                Some("9000"),
822                "{} {} went out without the proxy port header, which the proxy \
823                 rejects as if the token were bad",
824                request.method,
825                request.path
826            );
827        }
828    }
829
830    /// Health is unauthenticated and everything else is not.
831    ///
832    /// Both halves matter: health carrying a bearer would still work, but it would make
833    /// the route untestable as the unauthenticated route it is, and an exec route
834    /// *without* one would 401.
835    #[tokio::test]
836    async fn health_sends_no_bearer_and_an_exec_route_does() {
837        let recorder = Recorder::with([
838            Reply::ok(health_body(true)),
839            Reply::ok(serde_json::json!({"exec_id":"e1","phase":"running"})),
840        ]);
841        let (session, _, _) = session_with(Arc::clone(&recorder));
842
843        session.health().await.expect("health");
844        let seen = recorder.requests();
845        assert_eq!(header(&seen[0], "authorization"), None);
846        assert_eq!(
847            header(&seen[0], "x-microvms-core-token-intent"),
848            None,
849            "the internal token-intent marker must not reach the wire"
850        );
851
852        session.run(start_request("e1")).await.expect("start");
853        assert_eq!(
854            header(&recorder.last(), "authorization").as_deref(),
855            Some("Bearer agent-token-abcdef")
856        );
857    }
858
859    /// A session with no minter sends no proxy headers at all, which is the shape for
860    /// a daemon reached directly.
861    #[tokio::test]
862    async fn a_direct_session_sends_no_proxy_headers() {
863        let recorder = Recorder::with([Reply::ok(health_body(true))]);
864        let session = Session::builder("http://127.0.0.1:9000", "token")
865            .with_backend(Arc::clone(&recorder) as http::SharedBackend)
866            .build()
867            .expect("builds");
868        assert!(session.proxy_auth().is_none());
869
870        session.health().await.expect("health");
871        let request = recorder.last();
872        assert_eq!(header(&request, PROXY_AUTH_HEADER), None);
873        assert_eq!(header(&request, PROXY_PORT_HEADER), None);
874    }
875
876    /// A mint failure surfaces as a retryable error from the request, not from
877    /// construction — the whole point of minting in the request path.
878    #[tokio::test]
879    async fn a_mint_failure_surfaces_as_a_retryable_request_error() {
880        let recorder = Recorder::with([Reply::ok(health_body(true))]);
881        let auth = Arc::new(
882            ProxyAuth::with_refresh_after(
883                Arc::new(CountingMinter::failing(1)),
884                DEFAULT_AGENT_PORT,
885                DEFAULT_REFRESH_AFTER,
886                Arc::new(proxy::testing::ManualClock::default()),
887            )
888            .expect("interval accepted"),
889        );
890        let session = Session::builder("https://vm.example", "token")
891            .with_backend(Arc::clone(&recorder) as http::SharedBackend)
892            .with_proxy_auth(Arc::clone(&auth))
893            .build()
894            .expect("builds");
895
896        let err = session.health().await.expect_err("the mint fails");
897        assert_eq!(err.kind(), ErrorKind::Retryable);
898        assert_eq!(err.wire_kind(), Some(WireKind::AuthTokenMint));
899        assert!(
900            recorder.requests().is_empty(),
901            "a request went out without a token"
902        );
903
904        session.health().await.expect("the retry succeeds");
905        assert_eq!(auth.mint_count(), 1);
906    }
907
908    /// Every status the daemon chooses arrives as its own wire kind, from the session's
909    /// own surface rather than from the transport in isolation.
910    #[tokio::test]
911    async fn a_daemon_status_arrives_as_its_typed_error() {
912        for (status, expected) in [
913            (401, WireKind::Unauthorized),
914            (404, WireKind::NotFound),
915            (409, WireKind::Conflict),
916            (503, WireKind::NotBootstrapped),
917        ] {
918            let recorder = Recorder::with([Reply::Body(status, b"{\"error\":\"x\"}".to_vec())]);
919            let (session, _, _) = session_with(recorder);
920            let err = session.health().await.expect_err("a failing status");
921            assert_eq!(err.wire_kind(), Some(expected), "status {status}");
922        }
923    }
924
925    /// `wait_until_ready` returns as soon as the daemon reports bootstrapped, and
926    /// tolerates the pre-bootstrap answers before it.
927    #[tokio::test(start_paused = true)]
928    async fn wait_until_ready_polls_through_not_bootstrapped_and_a_dropped_connection() {
929        let recorder = Recorder::with([
930            Reply::Cut("connection refused"),
931            Reply::Body(503, b"not bootstrapped".to_vec()),
932            Reply::ok(health_body(false)),
933            Reply::ok(health_body(true)),
934        ]);
935        let (session, _, _) = session_with(Arc::clone(&recorder));
936
937        let health = session
938            .wait_until_ready(DEFAULT_READY_TIMEOUT)
939            .await
940            .expect("the daemon comes up");
941        assert!(health.bootstrapped);
942        assert_eq!(recorder.requests().len(), 4);
943    }
944
945    /// A fatal error ends the wait immediately: retrying a 401 until the deadline is
946    /// the mistake the retryable split exists to prevent.
947    #[tokio::test(start_paused = true)]
948    async fn wait_until_ready_gives_up_at_once_on_a_credential_failure() {
949        let recorder = Recorder::with([Reply::Body(401, b"wrong token".to_vec())]);
950        let (session, _, _) = session_with(Arc::clone(&recorder));
951
952        let err = session
953            .wait_until_ready(DEFAULT_READY_TIMEOUT)
954            .await
955            .expect_err("a 401 is fatal");
956        assert_eq!(err.kind(), ErrorKind::Credentials);
957        assert_eq!(
958            recorder.requests().len(),
959            1,
960            "a fatal error was retried, so a wrong token costs the full timeout"
961        );
962    }
963
964    /// The timeout is a client-side deadline and names the last retryable error, so a
965    /// caller can tell "never answered" from "answered, not ready".
966    #[tokio::test(start_paused = true)]
967    async fn wait_until_ready_times_out_naming_the_last_retryable_error() {
968        let recorder = Recorder::with([
969            Reply::Cut("connection refused"),
970            Reply::Cut("connection refused"),
971            Reply::Cut("connection refused"),
972        ]);
973        let (session, _, _) = session_with(recorder);
974
975        let err = session
976            .wait_until_ready(Duration::from_secs(4))
977            .await
978            .expect_err("the daemon never comes up");
979        assert_eq!(err.kind(), ErrorKind::Timeout);
980        assert!(
981            err.to_string().contains("connection refused"),
982            "the timeout must name what it kept hitting: {err}"
983        );
984    }
985
986    /// `rebind` drops the cached token, so the next request mints (STATE-8).
987    ///
988    /// The mint count is the assertion because a resumed VM keeps its endpoint URL:
989    /// nothing about the *request* changes, and the only observable is that a fresh
990    /// token was fetched.
991    #[tokio::test]
992    async fn rebind_invalidates_the_proxy_token_so_the_next_request_mints() {
993        let recorder = Recorder::with([Reply::ok(health_body(true)), Reply::ok(health_body(true))]);
994        let (mut session, auth, _) = session_with(Arc::clone(&recorder));
995
996        session.health().await.expect("health");
997        assert_eq!(auth.mint_count(), 1);
998
999        session.rebind("https://vm.example".to_string());
1000        assert!(!auth.is_cached(), "rebind did not drop the token");
1001
1002        session.health().await.expect("health");
1003        assert_eq!(auth.mint_count(), 2);
1004        assert_eq!(
1005            header(&recorder.last(), PROXY_AUTH_HEADER).as_deref(),
1006            Some(CountingMinter::value(1).as_str()),
1007            "the request after a rebind carried the pre-suspend token"
1008        );
1009    }
1010
1011    /// A start request's body is the protocol crate's serialization, and the handle
1012    /// addresses the id the daemon confirmed.
1013    #[tokio::test]
1014    async fn a_start_request_serializes_through_the_protocol_crate() {
1015        let recorder = Recorder::with([Reply::ok(
1016            serde_json::json!({"exec_id":"e-confirmed","phase":"running"}),
1017        )]);
1018        let (session, _, _) = session_with(Arc::clone(&recorder));
1019
1020        let handle = session.run(start_request("e-asked")).await.expect("start");
1021        assert_eq!(
1022            handle.exec_id(),
1023            "e-confirmed",
1024            "the handle addresses the id the client asked for rather than the one the \
1025             daemon confirmed, so every later call would 404 if they diverged"
1026        );
1027
1028        let request = recorder.last();
1029        assert_eq!(request.method, "POST");
1030        assert_eq!(request.path, "/v1/exec/start");
1031        let body: serde_json::Value = serde_json::from_slice(&request.body).expect("json body");
1032        assert_eq!(body["exec_id"], "e-asked");
1033        assert_eq!(body["command"], serde_json::json!(["/bin/true"]));
1034    }
1035
1036    /// A body this client cannot read is reported as a protocol failure naming the
1037    /// schema route, not as a transport error.
1038    #[tokio::test]
1039    async fn an_unreadable_response_body_names_the_schema_route() {
1040        let recorder = Recorder::with([Reply::Body(200, b"{\"unexpected\":true}".to_vec())]);
1041        let (session, _, _) = session_with(recorder);
1042        let err = session.health().await.expect_err("the body will not parse");
1043        assert_eq!(err.wire_kind(), Some(WireKind::ProtocolError));
1044        assert!(err.to_string().contains("/v1/schema"), "{err}");
1045    }
1046
1047    // ── credentials for a caller's own connection ─────────────────────────────
1048
1049    /// **A session hands out connect credentials for a port it was not built with, off one
1050    /// token cache, minting exactly once more to cover that port.**
1051    ///
1052    /// The property that makes an external `getPortEndpoint` a few lines rather than a
1053    /// reimplementation: one health request warms the cache, the first workload-port call
1054    /// widens the token, and the two accessors then share it.
1055    ///
1056    /// This test previously required `mint_count() == 1` after the workload-port calls —
1057    /// "resolving a port endpoint burned a second control-plane call". That was the defect
1058    /// written down as a requirement: a token scoped to the agent port does not authorize 8080,
1059    /// so the credentials it praised are refused with 403 `Access to port denied` on the header
1060    /// path and close code 1006 on the WebSocket path. Measured 2026-08-15 against real AWS.
1061    /// The one-cache claim survives in the form that is true — **both** accessors answer off
1062    /// the same widened token, which is still what stops TRAP-9 becoming two schedules.
1063    ///
1064    /// **Guard proof.** Give `connect_subprotocols` its own `ProxyAuth::new(..)` — the shape a
1065    /// consumer that could not reach this session's auth would be forced into — and the mint
1066    /// count reads 3 rather than 2, and the two accessors disagree on the auth value. Verified.
1067    #[tokio::test]
1068    async fn a_session_answers_connect_credentials_for_a_port_off_its_own_token_cache() {
1069        let recorder = Recorder::with([Reply::ok(health_body(true))]);
1070        let (session, auth, _) = session_with(Arc::clone(&recorder));
1071
1072        session.health().await.expect("health warms the cache");
1073        assert_eq!(auth.mint_count(), 1);
1074
1075        let headers = session
1076            .connect_headers(8080)
1077            .await
1078            .expect("the cached token answers");
1079        let offered = session
1080            .connect_subprotocols(8080)
1081            .await
1082            .expect("the cached token answers")
1083            .expect("a proxied session offers subprotocols");
1084
1085        assert_eq!(
1086            header_of(&headers, PROXY_PORT_HEADER).as_deref(),
1087            Some("8080"),
1088            "the caller asked for 8080 and would have targeted the session's port"
1089        );
1090        // The *widened* token, which is the second one minted: the first covers 9000 only.
1091        assert_eq!(
1092            header_of(&headers, PROXY_AUTH_HEADER).as_deref(),
1093            Some(CountingMinter::value(1).as_str()),
1094            "the header pair carried the agent-port-only token, which the proxy refuses for \
1095             8080 with 403 'Access to port denied'"
1096        );
1097        assert_eq!(offered[0], proxy::WS_SUBPROTOCOL);
1098        assert_eq!(
1099            offered[1],
1100            format!(
1101                "{}{}",
1102                proxy::WS_AUTH_SUBPROTOCOL_PREFIX,
1103                CountingMinter::value(1)
1104            ),
1105            "the handshake carried a different token than the header path, so they are two \
1106             caches rather than one"
1107        );
1108        assert_eq!(offered[2], "lambda-microvms.port.8080");
1109        assert_eq!(
1110            auth.mint_count(),
1111            2,
1112            "one mint covered both the agent port and 8080, which the control plane does not \
1113             do — or the second accessor minted a third, which would be two caches"
1114        );
1115        assert!(
1116            recorder.requests().len() == 1,
1117            "building credentials sent a request to the VM: {:?}",
1118            recorder.requests()
1119        );
1120    }
1121
1122    /// **A direct session offers no subprotocols at all, and that is not an empty list.**
1123    ///
1124    /// The asymmetry between the two accessors, asserted because it is the one thing a caller
1125    /// could reasonably get backwards. Empty headers are what a direct session's requests
1126    /// really carry, so `[]` there is a true answer. A subprotocol list *without* the token
1127    /// would be a false one: the handshake would be offered, refused, and the refusal would
1128    /// name neither the token nor the port.
1129    ///
1130    /// **Guard proof.** Return `Some([WS_SUBPROTOCOL.into(), String::new(), ..])` for the
1131    /// direct case and the `is_none` assertion is red. Verified.
1132    #[tokio::test]
1133    async fn a_direct_session_offers_no_subprotocols_and_empty_headers() {
1134        let recorder = Recorder::with([]);
1135        let session = Session::builder("http://127.0.0.1:9000", "token")
1136            .with_backend(recorder as http::SharedBackend)
1137            .build()
1138            .expect("builds");
1139
1140        assert!(
1141            session
1142                .connect_headers(9000)
1143                .await
1144                .expect("no proxy is not a failure")
1145                .is_empty(),
1146            "a direct session's requests carry no proxy headers, so neither should this"
1147        );
1148        assert!(
1149            session
1150                .connect_subprotocols(9000)
1151                .await
1152                .expect("no proxy is not a failure")
1153                .is_none(),
1154            "a handshake with the base subprotocol and no token is refused for a reason that \
1155             names neither"
1156        );
1157    }
1158
1159    /// **A connect credential built after the refresh window carries the fresh token.**
1160    ///
1161    /// TRAP-9 through this door. A WebSocket outlives a request, so a connection opened at
1162    /// minute fifty must hold a token minted at minute fifty — and the only observable is the
1163    /// value, because a stale token and a fresh one produce identical successful handshakes
1164    /// until one is rejected.
1165    ///
1166    /// **Guard proof.** Snapshot the subprotocols at `build()` and answer from the snapshot —
1167    /// the shape a caller who resolved a port endpoint once would get — and the second value
1168    /// still reads `jwe-0`. Verified.
1169    #[tokio::test]
1170    async fn connect_credentials_re_mint_when_the_refresh_window_has_rolled_over() {
1171        let recorder = Recorder::with([]);
1172        let (session, auth, clock) = session_with(recorder);
1173
1174        let first = session
1175            .connect_subprotocols(9000)
1176            .await
1177            .expect("mints")
1178            .expect("proxied");
1179        assert!(first[1].ends_with(&CountingMinter::value(0)));
1180
1181        clock.advance(DEFAULT_REFRESH_AFTER + Duration::from_secs(1));
1182        let second = session
1183            .connect_subprotocols(9000)
1184            .await
1185            .expect("re-mints")
1186            .expect("proxied");
1187
1188        assert_eq!(auth.mint_count(), 2);
1189        assert!(
1190            second[1].ends_with(&CountingMinter::value(1)),
1191            "the handshake would carry a token past its refresh window: {}",
1192            second[1]
1193        );
1194    }
1195
1196    /// A session's `Debug` does not print the agent token.
1197    #[test]
1198    fn a_session_debug_does_not_print_the_agent_token() {
1199        let recorder = Recorder::with([]);
1200        let session = Session::builder("https://vm.example", "super-secret-token")
1201            .with_backend(recorder as http::SharedBackend)
1202            .build()
1203            .expect("builds");
1204        let rendered = format!("{session:?}");
1205        assert!(rendered.contains("vm.example"), "{rendered}");
1206        assert!(
1207            !rendered.contains("super-secret"),
1208            "the agent token reached a Debug string: {rendered}"
1209        );
1210    }
1211}