Skip to main content

microvms_core/session/
mod.rs

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