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