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