Skip to main content

microvms_edges/session/
shell.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The client half of `microvm shell`: a real PTY carried over the endpoint's shell
3//! WebSocket. Issue #69, Option B — a thin client over the platform's session, not a
4//! line-mode wrapper over exec.
5//!
6//! Everything protocol-shaped in this module is measured, not inferred
7//! (`docs/PLATFORM.md`, "The shell endpoint is a real PTY over a WebSocket", 2026-08-15):
8//!
9//! * The endpoint is the VM's ordinary endpoint URL, opened as a WebSocket with **two**
10//!   subprotocols — the marker and the authentication value carrying the shell JWE.
11//!   There is **no port subprotocol**: the shell is not a port, and offering
12//!   `lambda-microvms.port.<n>` neither helps nor hurts, so this client offers exactly
13//!   what suffices.
14//! * The server sends one **text** frame on connect
15//!   (`{"type":"session_init","session_id":"<uuid>"}`), then **binary** frames carry raw
16//!   terminal bytes both ways. Client input is raw keystrokes: `0x03` raises SIGINT in
17//!   the guest, `"exit\n"` ends the shell.
18//! * A resize is a JSON **control frame**, `{"type":"resize","cols":120,"rows":40}`.
19//!   Before the first one the guest's `stty size` reports `0 0`, which is why
20//!   [`run_shell`] sends the caller's initial size unprompted.
21//! * A clean exit is close code **1000**, reason `shell exited`.
22//!
23//! # Sharp edge 1: a malformed control frame becomes keystrokes, not an error
24//!
25//! The platform does not reject an unrecognized control frame — it injects it into the
26//! shell as literal input. Measured: `{"type":"window_size",...}` produced
27//! `bash: type:window_sizestty: command not found` in the session. A typo in a control
28//! message corrupts the terminal instead of erroring, so **every** outbound text frame
29//! goes through [`validate_control_frame`] before it is sent, and the only constructor
30//! for one is [`resize_frame`]. A frame the validator does not recognize is refused on
31//! this side of the wire, where the failure has a message instead of a corrupted prompt.
32//!
33//! # Sharp edge 2: there is no exit-status channel, and this module does not invent one
34//!
35//! The shell's exit is a WebSocket close, full stop. There are no exec ids, no separated
36//! stdout/stderr, and no exit codes — which is precisely why this is its own surface and
37//! not a method on the exec path (TRAP-11). [`ShellEnd`] reports how the *transport*
38//! ended and nothing about the last command's status; a caller who needs a command's
39//! status must ask the shell itself (`echo $?`) and read the byte stream. Anything else
40//! would be a fabricated exit code.
41//!
42//! # The token gates the handshake, not the session
43//!
44//! The shell JWE comes from `CreateMicrovmShellAuthToken`
45//! ([`microvms_app::control::ControlPlane::mint_shell_auth_token`]) and is spent once, on the
46//! upgrade. An established session outlives the token's expiry, so there is no refresh
47//! loop here — one mint, one handshake, one PTY.
48
49use futures_util::{SinkExt as _, StreamExt as _};
50use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncWrite, AsyncWriteExt as _};
51use tokio_tungstenite::tungstenite::client::IntoClientRequest as _;
52use tokio_tungstenite::tungstenite::{Message, protocol::CloseFrame};
53
54use microvms_app::error::{Error, ErrorKind};
55use microvms_app::session::proxy::{WS_AUTH_SUBPROTOCOL_PREFIX, WS_SUBPROTOCOL};
56
57/// The close reason the platform sends when the shell process exits.
58///
59/// Recorded for diagnostics rather than matched on: the **code** (1000) is what
60/// classifies a close as the shell exiting, because a reason string is free text the
61/// platform could reword without an API change.
62pub const SHELL_EXITED_REASON: &str = "shell exited";
63
64/// Bytes read from the local input per relayed frame.
65///
66/// The tunnel's chunk, for the tunnel's reason: matching the daemon-side relay keeps
67/// either side from being the one that fragments a stream. Interactive input is single
68/// keystrokes almost always, so the size only matters for a paste.
69pub const SHELL_CHUNK_BYTES: usize = 64 * 1024;
70
71/// How a shell session ended. A value rather than an error, because every variant is a
72/// session that did its transport job — see the module docs on the missing exit-status
73/// channel before adding anything status-shaped here.
74#[derive(Clone, Debug, Eq, PartialEq)]
75pub enum ShellEnd {
76    /// The platform closed with code 1000: the shell process exited. Says nothing about
77    /// the last command's status — there is no channel that could.
78    Exited { reason: String },
79    /// The local input reached EOF, so this side hung up on a shell that was still
80    /// running. Distinct from [`ShellEnd::Exited`] because "the user closed their end"
81    /// and "the shell ended" are different facts to report.
82    LocalClosed,
83    /// The session ended some other way: a dropped connection, a proxy-side refusal, or
84    /// a close code this system does not originate. 1006 with no reason is what every
85    /// endpoint-proxy failure collapses to (the tunnel's measurement holds here too).
86    Disconnected { code: u16, reason: String },
87}
88
89/// What [`run_shell`] hands back: the session's identity and how it ended.
90#[derive(Clone, Debug)]
91pub struct ShellOutcome {
92    /// The `session_id` from the `session_init` frame, when one arrived. `None` means
93    /// the stream started without one — protocol drift worth surfacing, not an error
94    /// worth killing a working session over.
95    pub session_id: Option<String>,
96    pub end: ShellEnd,
97}
98
99/// The `wss://` URL for a VM's shell: the endpoint itself, no path.
100///
101/// The same scheme rule as [`crate::session::tunnel::tunnel_url`], for the same reason:
102/// the platform hands back a bare hostname, and defaulting a missing prefix to plaintext
103/// would put the shell credential on the wire in clear. Explicit `http://`/`ws://` stays
104/// plaintext for the local-daemon test case.
105pub fn shell_url(endpoint: &str) -> String {
106    let host = endpoint
107        .trim_end_matches('/')
108        .trim_start_matches("https://")
109        .trim_start_matches("http://")
110        .trim_start_matches("wss://")
111        .trim_start_matches("ws://");
112    let scheme = if endpoint.starts_with("http://") || endpoint.starts_with("ws://") {
113        "ws"
114    } else {
115        "wss"
116    };
117    format!("{scheme}://{host}/")
118}
119
120/// The two subprotocols a shell handshake offers: the marker and the credential.
121///
122/// Two, not three. Every other WebSocket through the proxy adds
123/// `lambda-microvms.port.<n>`, and the shell deliberately does not: the token names no
124/// port (`CreateMicrovmShellAuthToken` has no `allowedPorts` member) and the measured
125/// handshake succeeds without one. Offering the port value anyway would work today —
126/// measured as "neither helps nor hurts" — and would teach every reader that the shell
127/// is a port, which is the misunderstanding this crate keeps having to correct.
128pub fn shell_subprotocols(shell_jwe: &str) -> [String; 2] {
129    [
130        WS_SUBPROTOCOL.to_string(),
131        format!("{WS_AUTH_SUBPROTOCOL_PREFIX}{shell_jwe}"),
132    ]
133}
134
135/// A resize control frame, validated before it leaves this function.
136///
137/// The one control frame the platform speaks, built here and nowhere else. The
138/// validation call is not redundant with construction: it is the guard that stays in
139/// the path if this function grows a second frame shape, and it is the same check
140/// [`run_shell`] applies to anything it sends — so a frame that would be injected into
141/// the shell as keystrokes (sharp edge 1) cannot be produced by this crate at all.
142pub fn resize_frame(cols: u16, rows: u16) -> Result<String, Error> {
143    if cols == 0 || rows == 0 {
144        // A 0×0 resize is what the guest already reports before any resize arrives;
145        // sending one would set the size every full-screen program treats as "unknown".
146        // The caller passing 0 read a size from something that is not a terminal.
147        return Err(Error::new(
148            ErrorKind::InvalidArg,
149            format!(
150                "a terminal cannot be resized to {cols}x{rows}: a zero dimension is the \
151                 'no size known' value, not a size. Reading the size from something that \
152                 is not a terminal returns this."
153            ),
154        ));
155    }
156    let frame = format!("{{\"type\":\"resize\",\"cols\":{cols},\"rows\":{rows}}}");
157    validate_control_frame(&frame)?;
158    Ok(frame)
159}
160
161/// Refuses any outbound text frame the platform would not recognize as a control frame.
162///
163/// The platform's failure mode for an unrecognized control frame is to type it into the
164/// shell (sharp edge 1, measured: `{"type":"window_size",...}` became literal keystrokes
165/// and a corrupted prompt). So the rule here is a strict allowlist of what the platform
166/// is measured to accept — exactly `{"type":"resize","cols":<n>,"rows":<n>}` — and
167/// anything else is refused client-side, where the failure is a sentence rather than
168/// garbage in someone's terminal.
169pub fn validate_control_frame(text: &str) -> Result<(), Error> {
170    let refuse = |why: String| {
171        Error::new(
172            ErrorKind::InvalidArg,
173            format!(
174                "refusing to send a control frame the platform would not recognize: {why}. An \
175                 unrecognized control frame is not rejected — it is injected into the shell as \
176                 literal keystrokes (docs/PLATFORM.md, 2026-08-15). Frame: {text}"
177            ),
178        )
179    };
180
181    let value: serde_json::Value =
182        serde_json::from_str(text).map_err(|err| refuse(format!("it is not JSON ({err})")))?;
183    let object = value
184        .as_object()
185        .ok_or_else(|| refuse("it is JSON but not an object".to_string()))?;
186
187    match object.get("type").and_then(serde_json::Value::as_str) {
188        Some("resize") => {}
189        Some(other) => {
190            return Err(refuse(format!(
191                "\"{other}\" is not a control type the platform speaks; \"resize\" is the \
192                 only one"
193            )));
194        }
195        None => return Err(refuse("it has no string \"type\" member".to_string())),
196    }
197    for key in object.keys() {
198        if key != "type" && key != "cols" && key != "rows" {
199            return Err(refuse(format!(
200                "\"{key}\" is not a member of a resize frame"
201            )));
202        }
203    }
204    for dimension in ["cols", "rows"] {
205        let looks_sized = object
206            .get(dimension)
207            .and_then(serde_json::Value::as_u64)
208            .is_some_and(|size| size >= 1 && size <= u64::from(u16::MAX));
209        if !looks_sized {
210            return Err(refuse(format!(
211                "\"{dimension}\" must be an integer in 1..=65535"
212            )));
213        }
214    }
215    Ok(())
216}
217
218/// Opens the shell and relays raw terminal bytes until one side ends the session.
219///
220/// `input`/`output` are any async byte streams, so the CLI drives this with the process's
221/// raw-mode stdin/stdout and a test drives it with pipes. `resize` is a watch channel of
222/// `(cols, rows)`: the value at entry is sent immediately — the guest reports `0 0` until
223/// something does — and every change afterwards becomes one validated control frame.
224///
225/// Output is flushed per frame, because the reader on the other side of `output` is a
226/// human watching a prompt: a byte held in a buffer is a keystroke that appears to have
227/// been eaten.
228pub async fn run_shell<I, O>(
229    mut input: I,
230    mut output: O,
231    endpoint: &str,
232    shell_jwe: &str,
233    mut resize: tokio::sync::watch::Receiver<(u16, u16)>,
234) -> Result<ShellOutcome, Error>
235where
236    I: AsyncRead + Unpin,
237    O: AsyncWrite + Unpin,
238{
239    let url = shell_url(endpoint);
240    let mut request = url.as_str().into_client_request().map_err(|err| {
241        Error::new(
242            ErrorKind::InvalidArg,
243            format!("{url} is not a usable WebSocket URL: {err}"),
244        )
245    })?;
246    {
247        // The two platform values as one comma-separated list, exactly as the tunnel
248        // offers its three. No `authorization` header: the shell terminates at the
249        // platform's PTY, not at the daemon, so there is no bearer check on this path
250        // and the shell JWE in the subprotocol is the whole credential.
251        let offered = shell_subprotocols(shell_jwe);
252        request.headers_mut().insert(
253            "sec-websocket-protocol",
254            offered.join(", ").parse().map_err(|err| {
255                Error::new(
256                    ErrorKind::Unexpected,
257                    format!("the shell subprotocols are not a legal header value: {err}"),
258                )
259            })?,
260        );
261    }
262
263    let (mut socket, _response) =
264        tokio_tungstenite::connect_async(request)
265            .await
266            .map_err(|err| {
267                Error::new(
268                    ErrorKind::Unexpected,
269                    format!(
270                        "the shell handshake failed: {err}. Every endpoint-proxy WebSocket \
271                         failure is close code 1006 with no reason, so if this names nothing: \
272                         the shell token may have expired before the connect (it gates the \
273                         handshake, not the session), or the endpoint may be wrong. A VM \
274                         launched without SHELL_INGRESS fails earlier, at the token mint."
275                    ),
276                )
277            })?;
278
279    // The caller's size, sent before any keystroke: until a resize arrives the guest's
280    // line discipline believes the terminal is 0x0, and the first full-screen program
281    // the user starts would render into that. `borrow_and_update` rather than `borrow` +
282    // a later `mark_unchanged`: the caller's poller is already publishing into this
283    // channel, and clearing the change flag in a separate step would eat a resize that
284    // landed in between — the stored value would then be what future sizes are compared
285    // against, and the missed resize would never be re-sent.
286    let (cols, rows) = *resize.borrow_and_update();
287    if let Ok(frame) = resize_frame(cols, rows) {
288        // A (0, 0) initial value is not an error here: the caller read the size from
289        // something that is not a terminal (a pipe, a CI runner), and the session is
290        // still usable — the guest just keeps its size-unknown default.
291        socket
292            .send(Message::Text(frame.into()))
293            .await
294            .map_err(|err| {
295                Error::new(
296                    ErrorKind::Unexpected,
297                    format!("the shell closed before the initial resize could be sent: {err}"),
298                )
299            })?;
300    }
301
302    let mut session_id: Option<String> = None;
303    let mut buffer = vec![0_u8; SHELL_CHUNK_BYTES];
304    loop {
305        tokio::select! {
306            // Raw keystrokes from the local terminal, framed as binary, sent as-is.
307            read = input.read(&mut buffer) => match read {
308                Ok(0) => {
309                    // The local side is done sending. A close frame rather than a drop,
310                    // so the platform tears the PTY down instead of holding a session
311                    // whose keyboard has vanished.
312                    let _ = socket.send(Message::Close(None)).await;
313                    let _ = output.flush().await;
314                    return Ok(ShellOutcome { session_id, end: ShellEnd::LocalClosed });
315                }
316                Ok(count) => {
317                    if socket
318                        .send(Message::Binary(buffer[..count].to_vec().into()))
319                        .await
320                        .is_err()
321                    {
322                        let _ = output.flush().await;
323                        return Ok(ShellOutcome {
324                            session_id,
325                            end: ShellEnd::Disconnected {
326                                code: 1006,
327                                reason: "the connection dropped while sending input".to_string(),
328                            },
329                        });
330                    }
331                }
332                Err(err) => {
333                    return Err(Error::new(
334                        ErrorKind::Unexpected,
335                        format!("reading the local terminal failed: {err}"),
336                    ));
337                }
338            },
339            // A size change becomes one validated control frame. The validation is the
340            // sharp-edge-1 guard: nothing leaves this loop as text without passing it.
341            changed = resize.changed() => {
342                if changed.is_err() {
343                    // The sender is gone; the session continues at its current size.
344                    continue;
345                }
346                let (cols, rows) = *resize.borrow_and_update();
347                let frame = match resize_frame(cols, rows) {
348                    Ok(frame) => frame,
349                    // A transient 0 from a size probe is not worth ending a session over.
350                    Err(_) => continue,
351                };
352                if socket.send(Message::Text(frame.into())).await.is_err() {
353                    let _ = output.flush().await;
354                    return Ok(ShellOutcome {
355                        session_id,
356                        end: ShellEnd::Disconnected {
357                            code: 1006,
358                            reason: "the connection dropped while sending a resize".to_string(),
359                        },
360                    });
361                }
362            },
363            // Terminal bytes from the guest, written through and flushed per frame.
364            frame = socket.next() => match frame {
365                Some(Ok(Message::Binary(bytes))) => {
366                    if output.write_all(&bytes).await.is_err()
367                        || output.flush().await.is_err()
368                    {
369                        let _ = socket.send(Message::Close(None)).await;
370                        return Ok(ShellOutcome { session_id, end: ShellEnd::LocalClosed });
371                    }
372                }
373                Some(Ok(Message::Text(text))) => {
374                    // The one modeled text frame is the greeting. Anything after it is
375                    // protocol drift; written through rather than swallowed, because
376                    // hiding bytes from a terminal user is worse than showing them
377                    // something unexpected.
378                    if session_id.is_none()
379                        && let Some(id) = parse_session_init(&text)
380                    {
381                        session_id = Some(id);
382                        continue;
383                    }
384                    if output.write_all(text.as_bytes()).await.is_err()
385                        || output.flush().await.is_err()
386                    {
387                        let _ = socket.send(Message::Close(None)).await;
388                        return Ok(ShellOutcome { session_id, end: ShellEnd::LocalClosed });
389                    }
390                }
391                Some(Ok(Message::Close(frame))) => {
392                    let _ = output.flush().await;
393                    return Ok(ShellOutcome {
394                        session_id,
395                        end: classify_close(frame.as_ref()),
396                    });
397                }
398                Some(Ok(_)) => continue,
399                Some(Err(err)) => {
400                    let _ = output.flush().await;
401                    return Ok(ShellOutcome {
402                        session_id,
403                        end: ShellEnd::Disconnected {
404                            code: 1006,
405                            reason: format!("the connection failed mid-session: {err}"),
406                        },
407                    });
408                }
409                None => {
410                    let _ = output.flush().await;
411                    return Ok(ShellOutcome {
412                        session_id,
413                        end: ShellEnd::Disconnected {
414                            code: 1006,
415                            reason: "the connection ended with no close frame".to_string(),
416                        },
417                    });
418                }
419            },
420        }
421    }
422}
423
424/// Reads a `session_init` greeting, or `None` when the text is something else.
425///
426/// Deliberately narrow: only `{"type":"session_init",...}` with a string `session_id`
427/// parses. A text frame that is almost-but-not a greeting goes to the caller's output,
428/// per the drift stance in [`run_shell`].
429fn parse_session_init(text: &str) -> Option<String> {
430    let value: serde_json::Value = serde_json::from_str(text).ok()?;
431    if value.get("type")?.as_str()? != "session_init" {
432        return None;
433    }
434    Some(value.get("session_id")?.as_str()?.to_string())
435}
436
437/// Reads the platform's close frame into a [`ShellEnd`].
438///
439/// Code 1000 is the shell exiting — the **code** classifies, the reason is carried for
440/// display. A close with no frame at all is *not* the measured clean exit, so it is
441/// reported as a disconnect (1005, "no status received") rather than dressed up as one:
442/// the caller cannot know whether the shell ended or the proxy hung up.
443fn classify_close(frame: Option<&CloseFrame>) -> ShellEnd {
444    let Some(frame) = frame else {
445        return ShellEnd::Disconnected {
446            code: 1005,
447            reason: "the session closed with no close frame".to_string(),
448        };
449    };
450    let code: u16 = frame.code.into();
451    if code == 1000 {
452        return ShellEnd::Exited {
453            reason: frame.reason.to_string(),
454        };
455    }
456    ShellEnd::Disconnected {
457        code,
458        reason: frame.reason.to_string(),
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    #[test]
467    fn a_bare_endpoint_host_becomes_wss_at_the_root() {
468        // The tunnel's downgrade rule, applied to the shell: a missing prefix must not
469        // put the shell credential on a plaintext socket.
470        assert_eq!(shell_url("vm-abc.example.aws"), "wss://vm-abc.example.aws/");
471        assert_eq!(
472            shell_url("https://vm-abc.example.aws/"),
473            "wss://vm-abc.example.aws/"
474        );
475        // Explicit plaintext is a caller saying they know — the local test case.
476        assert_eq!(shell_url("http://127.0.0.1:9000"), "ws://127.0.0.1:9000/");
477    }
478
479    /// Two subprotocols, and **no port value** — the absence is the measured protocol,
480    /// not an omission. The marker stays aliased to the protocol crate's constant.
481    ///
482    /// **Falsification** — watched fail 2026-08-31: appending
483    /// `lambda-microvms.port.9000` to [`shell_subprotocols`] fails the length assertion,
484    /// and the no-port scan fails with the offending value in the message.
485    #[test]
486    fn the_shell_offers_two_subprotocols_and_no_port() {
487        let offered = shell_subprotocols("opaque-jwe");
488        assert_eq!(offered.len(), 2);
489        assert_eq!(offered[0], "lambda-microvms");
490        assert_eq!(offered[1], "lambda-microvms.authentication.opaque-jwe");
491        for value in &offered {
492            assert!(
493                !value.starts_with("lambda-microvms.port."),
494                "the shell is not a port, so no handshake value may name one: {value}"
495            );
496        }
497    }
498
499    /// The resize frame, byte for byte the measured shape. Pinned as a literal because
500    /// the platform parses this with something that types unrecognized frames into the
501    /// shell — the frame's exact spelling is the contract.
502    #[test]
503    fn the_resize_frame_is_the_measured_shape_byte_for_byte() {
504        assert_eq!(
505            resize_frame(120, 40).expect("a legal size"),
506            r#"{"type":"resize","cols":120,"rows":40}"#
507        );
508    }
509
510    /// A zero dimension is refused with the reason: 0×0 is the guest's "no size known"
511    /// state, not a size.
512    #[test]
513    fn a_zero_dimension_is_refused() {
514        for (cols, rows) in [(0, 40), (120, 0), (0, 0)] {
515            let error = resize_frame(cols, rows).expect_err("zero is not a size");
516            assert_eq!(error.kind(), ErrorKind::InvalidArg);
517        }
518    }
519
520    /// **Sharp edge 1, as a guard.** The exact frame the measurement watched corrupt a
521    /// session — `{"type":"window_size",...}` — is refused client-side, along with every
522    /// other shape the platform would type into the shell as keystrokes.
523    ///
524    /// **Falsification** — watched fail 2026-08-31: making `validate_control_frame`
525    /// accept any object with a `"type"` member passes the `window_size` case straight
526    /// through, and this test fails on it.
527    #[test]
528    fn a_control_frame_the_platform_would_not_recognize_is_refused_before_the_wire() {
529        // The measured corruption, verbatim from docs/PLATFORM.md.
530        let measured = r#"{"type":"window_size","cols":120,"rows":40}"#;
531        let error = validate_control_frame(measured).expect_err("window_size corrupts");
532        let message = error.to_string();
533        assert!(message.contains("window_size"), "{message}");
534        assert!(
535            message.contains("literal keystrokes"),
536            "the message must say what the platform does with it: {message}"
537        );
538
539        // The rest of the refusal surface: not JSON, not an object, no type, an extra
540        // member, and a dimension that is missing, non-numeric, zero, or oversized.
541        for bad in [
542            "resize 120 40",
543            "[1,2,3]",
544            r#"{"cols":120,"rows":40}"#,
545            r#"{"type":"resize","cols":120,"rows":40,"pad":1}"#,
546            r#"{"type":"resize","cols":120}"#,
547            r#"{"type":"resize","cols":"120","rows":40}"#,
548            r#"{"type":"resize","cols":0,"rows":40}"#,
549            r#"{"type":"resize","cols":120,"rows":65536}"#,
550        ] {
551            assert!(validate_control_frame(bad).is_err(), "must refuse: {bad}");
552        }
553
554        // And the one legal frame still passes, or the guard is a wall rather than a door.
555        validate_control_frame(r#"{"type":"resize","cols":120,"rows":40}"#)
556            .expect("the measured frame is legal");
557    }
558
559    /// Close classification: 1000 is the shell exiting whatever the reason says; any
560    /// other code — and a missing frame — is a disconnect, never dressed up as an exit.
561    #[test]
562    fn only_code_1000_is_the_shell_exiting() {
563        let clean = CloseFrame {
564            code: 1000.into(),
565            reason: SHELL_EXITED_REASON.into(),
566        };
567        assert_eq!(
568            classify_close(Some(&clean)),
569            ShellEnd::Exited {
570                reason: "shell exited".to_string()
571            }
572        );
573
574        // The reason is display material, not the classifier: a reworded platform
575        // string must not turn clean exits into failures.
576        let reworded = CloseFrame {
577            code: 1000.into(),
578            reason: "session complete".into(),
579        };
580        assert!(matches!(
581            classify_close(Some(&reworded)),
582            ShellEnd::Exited { .. }
583        ));
584
585        // No frame is not the measured clean exit, and saying "exited" would fake a
586        // fact nobody has.
587        assert_eq!(
588            classify_close(None),
589            ShellEnd::Disconnected {
590                code: 1005,
591                reason: "the session closed with no close frame".to_string()
592            }
593        );
594
595        let dropped = CloseFrame {
596            code: 1006.into(),
597            reason: String::new().into(),
598        };
599        assert!(matches!(
600            classify_close(Some(&dropped)),
601            ShellEnd::Disconnected { code: 1006, .. }
602        ));
603    }
604
605    /// The greeting parses, and near-greetings do not: a text frame that is almost a
606    /// `session_init` goes to the terminal rather than being silently eaten.
607    #[test]
608    fn the_session_init_greeting_parses_and_near_misses_do_not() {
609        assert_eq!(
610            parse_session_init(r#"{"type":"session_init","session_id":"abc-123"}"#),
611            Some("abc-123".to_string())
612        );
613        for near_miss in [
614            r#"{"type":"session_start","session_id":"abc"}"#,
615            r#"{"type":"session_init"}"#,
616            r#"{"type":"session_init","session_id":7}"#,
617            "not json",
618        ] {
619            assert_eq!(parse_session_init(near_miss), None, "{near_miss}");
620        }
621    }
622}