Skip to main content

podbox/
socket_host.rs

1use std::collections::HashSet;
2use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
3use std::os::unix::net::{UnixListener, UnixStream};
4use std::path::Path;
5use std::sync::Arc;
6use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
7use std::time::Duration;
8
9use nix::sys::signal::{SaFlags, SigAction, SigHandler, SigSet, Signal, sigaction};
10use nix::sys::socket::{getsockopt, sockopt};
11
12use crate::config::Config;
13use crate::config::validation::parse_idle_timeout_secs;
14use crate::process;
15use crate::protocol::{GuestMessage, HostMessage, read_frame, write_frame};
16use crate::systemd;
17
18mod handlers;
19
20/// Max number of concurrent host threads handling guest connections.
21const MAX_CONCURRENT: usize = 4;
22
23/// Max number of tracked terminal sessions (pidfd monitors).
24const MAX_SESSIONS: u32 = 64;
25
26/// How often the host sends a keepalive `Ping` to a connected guest.
27const PING_INTERVAL: Duration = Duration::from_mins(1);
28
29static SHUTDOWN_REQUESTED: AtomicBool = AtomicBool::new(false);
30
31/// Register SIGTERM/SIGINT handlers that set `SHUTDOWN_REQUESTED`.
32/// Without SA_RESTART, blocking syscalls return EINTR, letting the
33/// accept loop check the flag.
34fn setup_signal_handler() -> nix::Result<()> {
35    extern "C" fn handle_signal(_: i32) {
36        SHUTDOWN_REQUESTED.store(true, Ordering::Relaxed);
37    }
38    let sig_action = SigAction::new(
39        SigHandler::Handler(handle_signal),
40        SaFlags::empty(),
41        SigSet::empty(),
42    );
43    unsafe {
44        sigaction(Signal::SIGTERM, &sig_action)?;
45        sigaction(Signal::SIGINT, &sig_action)?;
46    }
47    Ok(())
48}
49
50/// Shared mutable state between all connections and PID monitor threads.
51struct SharedState {
52    /// Number of active terminal sessions tracked via pidfd.
53    session_count: AtomicU32,
54    /// Container name, for `systemctl stop` on idle timeout.
55    container_name: String,
56    /// Idle timeout in seconds (0 = disabled).
57    idle_timeout_secs: u64,
58    /// Whether this process was launched via systemd socket activation
59    /// (`LISTEN_PID`/`LISTEN_FDS` set). If true, the process may
60    /// self-terminate on idle timeout — systemd will re-spawn it via
61    /// socket activation on the next connection.
62    was_socket_activated: bool,
63}
64
65/// Run the host socket server for a container.
66pub fn run(socket_path: &Path, config: &Config, container_name: &str) -> anyhow::Result<()> {
67    let _ = setup_signal_handler();
68
69    let config = config.clone();
70    let path = socket_path.to_path_buf();
71    let idle_timeout_secs = parse_idle_timeout_secs(&config.lifecycle.idle_timeout);
72
73    let activation_fd = listen_fd();
74    let was_socket_activated = activation_fd.is_some();
75    let listener = match activation_fd {
76        Some(fd) => unsafe { UnixListener::from_raw_fd(fd) },
77        None => {
78            let _ = std::fs::remove_file(&path);
79            UnixListener::bind(&path)?
80        }
81    };
82
83    let state = Arc::new(SharedState {
84        session_count: AtomicU32::new(0),
85        container_name: container_name.to_string(),
86        idle_timeout_secs,
87        was_socket_activated,
88    });
89
90    let mut handles: Vec<std::thread::JoinHandle<()>> = Vec::new();
91
92    loop {
93        if SHUTDOWN_REQUESTED.load(Ordering::Relaxed) {
94            tracing::info!("podbox: shutdown requested, draining connections...");
95            drop(listener);
96            for h in handles {
97                let _ = h.join();
98            }
99            return Ok(());
100        }
101
102        match listener.accept() {
103            Ok((mut stream, _)) => {
104                handles.retain_mut(|h| !h.is_finished());
105
106                if handles.len() >= MAX_CONCURRENT {
107                    tracing::warn!(
108                        "dropping connection: {} concurrent clients already in flight",
109                        handles.len()
110                    );
111                    continue;
112                }
113
114                let cfg = config.clone();
115                let state = Arc::clone(&state);
116                let handle = std::thread::spawn(move || {
117                    if let Err(e) = handle_connection(&mut stream, &cfg, &state) {
118                        tracing::error!("error handling connection: {}", e);
119                    }
120                });
121                handles.push(handle);
122            }
123            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
124            Err(e) => {
125                tracing::error!("socket accept failed: {}", e);
126                break;
127            }
128        }
129    }
130
131    Ok(())
132}
133
134fn listen_fd() -> Option<RawFd> {
135    let pid = std::env::var("LISTEN_PID").ok()?.parse::<u32>().ok()?;
136    if pid != std::process::id() {
137        return None;
138    }
139    let fds = std::env::var("LISTEN_FDS").ok()?.parse::<u32>().ok()?;
140    if fds == 0 {
141        return None;
142    }
143    Some(3)
144}
145
146fn handle_connection(
147    stream: &mut UnixStream,
148    config: &Config,
149    state: &Arc<SharedState>,
150) -> anyhow::Result<()> {
151    stream.set_read_timeout(Some(PING_INTERVAL))?;
152    let mut last_ping = std::time::Instant::now();
153    // Capabilities accepted for this connection during `Hello`. Privileged
154    // messages are rejected until this is populated, and each is further
155    // gated on the specific capability the admin enabled.
156    let mut negotiated: Option<HashSet<String>> = None;
157    // Consecutive failed negotiations. The connection is dropped (fail-closed)
158    // once this reaches `MAX_NEGOTIATION_FAILURES`.
159    let mut failures: u32 = 0;
160
161    loop {
162        let msg_bytes = match read_frame(stream) {
163            Ok(Some(b)) => b,
164            Ok(None) => return Ok(()),
165            Err(e)
166                if e.kind() == std::io::ErrorKind::WouldBlock
167                    || e.kind() == std::io::ErrorKind::TimedOut =>
168            {
169                if last_ping.elapsed() >= PING_INTERVAL {
170                    if write_frame(stream, &HostMessage::Ping).is_err() {
171                        return Ok(());
172                    }
173                    last_ping = std::time::Instant::now();
174                }
175                continue;
176            }
177            Err(e) => return Err(e.into()),
178        };
179
180        last_ping = std::time::Instant::now();
181        let msg: GuestMessage = match serde_json::from_slice(&msg_bytes) {
182            Ok(m) => m,
183            Err(e) => {
184                tracing::warn!("malformed frame from peer: {e}");
185                if note_failure(stream, &mut failures, "malformed frame") {
186                    return Ok(());
187                }
188                continue;
189            }
190        };
191
192        match msg {
193            GuestMessage::Hello {
194                protocol_version,
195                guest_version,
196                container,
197                capabilities,
198            } => {
199                let outcome = handlers::handle_hello(
200                    stream,
201                    &config.integration,
202                    state.idle_timeout_secs,
203                    protocol_version,
204                    guest_version,
205                    container,
206                    capabilities,
207                )?;
208                let handlers::HelloOutcome::Accepted(accepted) = outcome else {
209                    // Failed negotiation: no capabilities granted, daemon stream
210                    // stays unclaimed. Drop after repeated failures.
211                    if note_failure(stream, &mut failures, "hello rejected") {
212                        return Ok(());
213                    }
214                    continue;
215                };
216                negotiated = Some(accepted.into_iter().collect());
217
218                // Idle shutdown is driven entirely by the guest's own idle
219                // timer (which respects idle_timeout). The host must NOT
220                // probe the daemon immediately on hello: a container that was
221                // just started is, by definition, not yet idle, and stopping
222                // it at hello races `podman enter`, which is trying to spawn
223                // a session into it. An immediate check would kill the box
224                // before the user's shell can start.
225            }
226            GuestMessage::RegisterSession => {
227                // host-CLI-only: the peer must be inside the host user
228                // namespace (i.e. running on the host, not in the container).
229                if !peer_is_in_host_userns(stream) {
230                    tracing::warn!("rejecting RegisterSession from foreign user namespace");
231                    let _ = write_frame(
232                        stream,
233                        &HostMessage::Error {
234                            reason: "register_session is host-only".into(),
235                        },
236                    );
237                    return Ok(());
238                }
239                if state.session_count.load(Ordering::SeqCst) >= MAX_SESSIONS {
240                    tracing::warn!("rejecting RegisterSession: session cap reached");
241                    let _ = write_frame(
242                        stream,
243                        &HostMessage::Error {
244                            reason: "session limit reached".into(),
245                        },
246                    );
247                    return Ok(());
248                }
249                // Receive the pidfd via SCM_RIGHTS
250                let raw_fd = match process::recv_fd(stream) {
251                    Ok(Some(fd)) => fd,
252                    Ok(None) => return Ok(()),
253                    Err(_) => return Ok(()),
254                };
255                let fd = unsafe { OwnedFd::from_raw_fd(raw_fd) };
256                state.session_count.fetch_add(1, Ordering::SeqCst);
257                let s = Arc::clone(state);
258                std::thread::spawn(move || monitor_pidfd(fd, s));
259                // Return immediately — the CLI closes the connection after
260                // sending RegisterSession + pidfd.
261                return Ok(());
262            }
263            GuestMessage::Busy => {
264                if negotiated.is_none() {
265                    if note_failure(stream, &mut failures, "hello required") {
266                        return Ok(());
267                    }
268                }
269            }
270            GuestMessage::IdleTimeout => {
271                if negotiated.is_none() {
272                    if note_failure(stream, &mut failures, "hello required") {
273                        return Ok(());
274                    }
275                    continue;
276                }
277                if state.idle_timeout_secs > 0 {
278                    let name = &state.container_name;
279                    tracing::info!("container '{}' idle — stopping", name);
280                    let _ = systemd::stop_unit(name);
281                    // If socket-activated, self-terminate so the host
282                    // service doesn't sit resident forever.  systemd
283                    // re-spawns it via socket activation on the next
284                    // connection.  Non-systemd (manual bind) must stay
285                    // alive — it has no re-launch mechanism.
286                    if state.was_socket_activated {
287                        std::process::exit(0);
288                    }
289                }
290            }
291            GuestMessage::Notify {
292                summary,
293                body,
294                urgency: _,
295                actions,
296                app_name: _,
297            } => {
298                if !has_cap(&negotiated, crate::protocol::CAP_NOTIFY) {
299                    if note_failure(stream, &mut failures, "capability 'notify' not accepted") {
300                        return Ok(());
301                    }
302                    continue;
303                }
304                handlers::handle_notify(stream, summary, body, actions)?
305            }
306            GuestMessage::XdgOpen { uri } => {
307                if !has_cap(&negotiated, crate::protocol::CAP_XDG_OPEN) {
308                    if note_failure(stream, &mut failures, "capability 'xdg_open' not accepted") {
309                        return Ok(());
310                    }
311                    continue;
312                }
313                handlers::handle_xdg_open(uri)?
314            }
315            GuestMessage::ClipboardSet { text } => {
316                if !has_cap(&negotiated, crate::protocol::CAP_CLIPBOARD) {
317                    if note_failure(stream, &mut failures, "capability 'clipboard' not accepted") {
318                        return Ok(());
319                    }
320                    continue;
321                }
322                handlers::handle_clipboard_set(text)?
323            }
324            GuestMessage::ClipboardGet => {
325                if !has_cap(&negotiated, crate::protocol::CAP_CLIPBOARD) {
326                    if note_failure(stream, &mut failures, "capability 'clipboard' not accepted") {
327                        return Ok(());
328                    }
329                    continue;
330                }
331                handlers::handle_clipboard_get(stream)?
332            }
333            GuestMessage::HostExec { cmd, args } => {
334                if !has_cap(&negotiated, crate::protocol::CAP_HOST_EXEC) {
335                    if note_failure(stream, &mut failures, "capability 'host_exec' not accepted") {
336                        return Ok(());
337                    }
338                    continue;
339                }
340                handlers::handle_host_exec(stream, &config.integration, cmd, args)?
341            }
342        }
343    }
344}
345
346/// Maximum consecutive failed negotiations (bad hello, unauthenticated
347/// privileged message, malformed frame) before the connection is dropped.
348const MAX_NEGOTIATION_FAILURES: u32 = 5;
349
350/// Count a failed negotiation and reply with a typed `Error` frame. Returns
351/// true once the connection should be dropped (fail-closed after
352/// `MAX_NEGOTIATION_FAILURES` failures).
353fn note_failure(stream: &mut UnixStream, failures: &mut u32, reason: &str) -> bool {
354    *failures = failures.saturating_add(1);
355    let _ = write_frame(
356        stream,
357        &HostMessage::Error {
358            reason: reason.to_string(),
359        },
360    );
361    *failures >= MAX_NEGOTIATION_FAILURES
362}
363
364/// True if `negotiated` contains the given capability.
365fn has_cap(negotiated: &Option<HashSet<String>>, cap: &str) -> bool {
366    negotiated
367        .as_ref()
368        .is_some_and(|caps| caps.iter().any(|c| c == cap))
369}
370
371/// Whether the peer of `stream` lives in the host user namespace.
372///
373/// Compares `SO_PEERCRED`'s pid against `/proc/self/ns/user`. The host CLI
374/// runs in the host userns; anything inside the container runs in the
375/// container's private userns (rootless podman), so the inode differs.
376fn peer_is_in_host_userns(stream: &UnixStream) -> bool {
377    let creds = match getsockopt(stream, sockopt::PeerCredentials) {
378        Ok(c) => c,
379        Err(_) => return false,
380    };
381    let self_ns = std::fs::read_link("/proc/self/ns/user").ok();
382    let peer_ns = std::fs::read_link(format!("/proc/{}/ns/user", creds.pid())).ok();
383    match (self_ns, peer_ns) {
384        (Some(a), Some(b)) => a == b,
385        _ => false,
386    }
387}
388
389/// Block until `fd` (a pidfd) becomes readable, then decrement the session
390/// counter.
391fn monitor_pidfd(fd: OwnedFd, state: Arc<SharedState>) {
392    let mut pfd = nix::libc::pollfd {
393        fd: fd.as_raw_fd(),
394        events: nix::libc::POLLIN,
395        revents: 0,
396    };
397
398    loop {
399        let ret = unsafe { nix::libc::poll(&raw mut pfd, 1, -1) };
400        if ret < 0 {
401            let errno = unsafe { *nix::libc::__errno_location() };
402            if errno == nix::libc::EINTR {
403                continue;
404            }
405            break;
406        }
407        if pfd.revents & (nix::libc::POLLIN | nix::libc::POLLHUP | nix::libc::POLLERR) != 0 {
408            break;
409        }
410    }
411
412    let _ = state.session_count.fetch_sub(1, Ordering::SeqCst);
413    // Idle shutdown is driven entirely by the guest's own idle timer, so
414    // there is no host-side work to do here.
415}
416
417#[cfg(test)]
418mod tests {
419    use super::handlers::{validate_host_exec_args, validate_uri};
420    use super::{has_cap, note_failure};
421    use std::collections::HashSet;
422
423    // ── validate_uri tests ──
424
425    #[test]
426    fn allows_http_https_mailto() {
427        assert_eq!(
428            validate_uri("https://example.com"),
429            Some("https://example.com".to_string())
430        );
431        assert_eq!(
432            validate_uri("http://example.com"),
433            Some("http://example.com".to_string())
434        );
435        assert_eq!(
436            validate_uri("mailto:user@host"),
437            Some("mailto:user@host".to_string())
438        );
439    }
440
441    #[test]
442    fn refuses_path_traversal() {
443        assert_eq!(validate_uri("/etc/passwd"), None);
444        assert_eq!(validate_uri("../foo"), None);
445        assert_eq!(validate_uri(""), None);
446    }
447
448    #[test]
449    fn refuses_unknown_alphabetic_schemes() {
450        assert_eq!(validate_uri("javascript:alert(1)"), None);
451        assert_eq!(validate_uri("file:///etc/passwd"), None);
452    }
453
454    #[test]
455    fn wraps_bare_domain() {
456        assert_eq!(
457            validate_uri("example.com"),
458            Some("https://example.com".to_string())
459        );
460    }
461
462    #[test]
463    fn trims_whitespace() {
464        assert_eq!(
465            validate_uri("  https://example.com  "),
466            Some("https://example.com".to_string())
467        );
468    }
469
470    // ── validate_host_exec_args tests ──
471
472    #[test]
473    fn accepts_plain_args() {
474        assert!(validate_host_exec_args(&["ls".into()]).is_ok());
475        assert!(validate_host_exec_args(&["ls".into(), "-la".into(), "/tmp".into()]).is_ok());
476        assert!(validate_host_exec_args(&["git".into(), "log".into(), "--oneline".into()]).is_ok());
477    }
478
479    #[test]
480    fn rejects_shell_metacharacters() {
481        assert!(validate_host_exec_args(&["echo".into(), "foo;bar".into()]).is_err());
482        assert!(validate_host_exec_args(&["echo".into(), "foo|bar".into()]).is_err());
483        assert!(validate_host_exec_args(&["echo".into(), "foo&bar".into()]).is_err());
484        assert!(validate_host_exec_args(&["echo".into(), "$PATH".into()]).is_err());
485        assert!(validate_host_exec_args(&["echo".into(), "`ls`".into()]).is_err());
486    }
487
488    #[test]
489    fn rejects_redirection_operators() {
490        assert!(validate_host_exec_args(&["cat".into(), "<file".into()]).is_err());
491        assert!(validate_host_exec_args(&["echo".into(), ">file".into()]).is_err());
492        assert!(validate_host_exec_args(&["echo".into(), ">>file".into()]).is_err());
493    }
494
495    #[test]
496    fn rejects_glob_and_brace_chars() {
497        assert!(validate_host_exec_args(&["ls".into(), "*.rs".into()]).is_err());
498        assert!(validate_host_exec_args(&["ls".into(), "file?".into()]).is_err());
499        assert!(validate_host_exec_args(&["ls".into(), "[abc]".into()]).is_err());
500        assert!(validate_host_exec_args(&["echo".into(), "{a,b}".into()]).is_err());
501    }
502
503    #[test]
504    fn rejects_subshell_and_escape_chars() {
505        assert!(validate_host_exec_args(&["echo".into(), "$(whoami)".into()]).is_err());
506        assert!(validate_host_exec_args(&["echo".into(), "line1\nline2".into()]).is_err());
507    }
508
509    #[test]
510    fn rejects_restricted_flag_patterns() {
511        assert!(validate_host_exec_args(&["git".into(), "--exec-path=/tmp".into()]).is_err());
512        assert!(validate_host_exec_args(&["git".into(), "--config=user.name".into()]).is_err());
513        assert!(validate_host_exec_args(&["vim".into(), "--plugin=malicious".into()]).is_err());
514        assert!(validate_host_exec_args(&["python".into(), "--load=malicious".into()]).is_err());
515        assert!(validate_host_exec_args(&["python".into(), "--module=malicious".into()]).is_err());
516        assert!(validate_host_exec_args(&["git".into(), "--remote=evil".into()]).is_err());
517        assert!(
518            validate_host_exec_args(&[
519                "ssh".into(),
520                "-o".into(),
521                "StrictHostKeyChecking=no".into()
522            ])
523            .is_err()
524        );
525    }
526
527    #[test]
528    fn restricted_flag_detection_is_case_insensitive() {
529        assert!(validate_host_exec_args(&["git".into(), "--EXEC-PATH=/tmp".into()]).is_err());
530        assert!(validate_host_exec_args(&["GIT".into(), "--Config=evil".into()]).is_err());
531    }
532
533    #[test]
534    fn does_not_restrict_safe_flags() {
535        assert!(validate_host_exec_args(&["git".into(), "--exec".into()]).is_ok());
536        assert!(
537            validate_host_exec_args(&["git".into(), "--exec-path-is-ok".into()]).is_err(),
538            "--exec-path prefix still blocked"
539        );
540        assert!(validate_host_exec_args(&["ls".into(), "--color=auto".into()]).is_ok());
541        assert!(validate_host_exec_args(&["cargo".into(), "--offline".into()]).is_ok());
542    }
543
544    #[test]
545    fn rejects_empty_args_gracefully() {
546        assert!(
547            validate_host_exec_args(&[String::new()]).is_ok(),
548            "empty string is not a metachar"
549        );
550    }
551
552    #[test]
553    fn ascii_lowercase_only() {
554        assert!(validate_host_exec_args(&["git".into(), "--EXEC-PATH=".into()]).is_err());
555        assert!(
556            validate_host_exec_args(&["git".into(), "--\u{0130}".into()]).is_ok(),
557            "Turkish \u{0130} is non-ASCII"
558        );
559    }
560
561    // ── has_cap tests ──
562
563    #[test]
564    fn has_cap_none_negotiated_rejects() {
565        assert!(!has_cap(&None, crate::protocol::CAP_NOTIFY));
566        assert!(!has_cap(&None, crate::protocol::CAP_CLIPBOARD));
567    }
568
569    #[test]
570    fn has_cap_accepts_negotiated() {
571        let caps = HashSet::from(["notify".to_string(), "clipboard".to_string()]);
572        let negotiated = Some(caps);
573        assert!(has_cap(&negotiated, crate::protocol::CAP_NOTIFY));
574        assert!(has_cap(&negotiated, crate::protocol::CAP_CLIPBOARD));
575        assert!(!has_cap(&negotiated, crate::protocol::CAP_XDG_OPEN));
576        assert!(!has_cap(&negotiated, crate::protocol::CAP_HOST_EXEC));
577    }
578
579    // ── note_failure tests ──
580
581    fn socket_pair() -> (
582        std::os::unix::net::UnixStream,
583        std::os::unix::net::UnixStream,
584    ) {
585        std::os::unix::net::UnixStream::pair().expect("socketpair")
586    }
587
588    #[test]
589    fn note_failure_is_false_below_threshold() {
590        let (mut server, _client) = socket_pair();
591        let mut failures: u32 = 0;
592        for _ in 0..(super::MAX_NEGOTIATION_FAILURES - 1) {
593            assert!(!note_failure(&mut server, &mut failures, "probe"));
594        }
595        assert_eq!(failures, super::MAX_NEGOTIATION_FAILURES - 1);
596    }
597
598    #[test]
599    fn note_failure_drops_after_threshold() {
600        let (mut server, mut client) = socket_pair();
601        let mut failures: u32 = 0;
602        for _ in 0..(super::MAX_NEGOTIATION_FAILURES - 1) {
603            assert!(!note_failure(&mut server, &mut failures, "probe"));
604        }
605        assert!(note_failure(&mut server, &mut failures, "probe"));
606        assert_eq!(failures, super::MAX_NEGOTIATION_FAILURES);
607
608        // Every failure was answered with a typed Error frame.
609        use crate::protocol::read_frame;
610        for _ in 0..super::MAX_NEGOTIATION_FAILURES {
611            let bytes = read_frame(&mut client).unwrap().expect("error frame");
612            let msg: crate::protocol::HostMessage = serde_json::from_slice(&bytes).unwrap();
613            assert!(
614                matches!(msg, crate::protocol::HostMessage::Error { reason } if reason == "probe")
615            );
616        }
617    }
618
619    // ── handle_hello tests ──
620
621    #[test]
622    fn hello_protocol_mismatch_is_rejected() {
623        use super::handlers::{HelloOutcome, handle_hello};
624        let (mut server, mut client) = socket_pair();
625        let config = crate::config::Config::embedded();
626        let outcome = handle_hello(
627            &mut server,
628            &config.integration,
629            0,
630            crate::protocol::PROTOCOL_VERSION + 1,
631            "test".into(),
632            "test".into(),
633            vec![],
634        )
635        .unwrap();
636        assert!(matches!(outcome, HelloOutcome::Rejected));
637
638        // The peer is told to shut down, and no capabilities are granted.
639        let bytes = crate::protocol::read_frame(&mut client)
640            .unwrap()
641            .expect("shutdown frame");
642        let msg: crate::protocol::HostMessage = serde_json::from_slice(&bytes).unwrap();
643        assert!(matches!(msg, crate::protocol::HostMessage::Shutdown));
644    }
645
646    #[test]
647    fn hello_accepts_enabled_capabilities() {
648        use super::handlers::{HelloOutcome, handle_hello};
649        let (mut server, mut client) = socket_pair();
650        let mut config = crate::config::Config::embedded();
651        config.integration.notify = true;
652        config.integration.clipboard = true;
653        config.integration.xdg_open = false;
654        let outcome = handle_hello(
655            &mut server,
656            &config.integration,
657            0,
658            crate::protocol::PROTOCOL_VERSION,
659            "test".into(),
660            "test".into(),
661            vec![
662                crate::protocol::CAP_NOTIFY.to_string(),
663                crate::protocol::CAP_XDG_OPEN.to_string(),
664            ],
665        )
666        .unwrap();
667        let HelloOutcome::Accepted(accepted) = outcome else {
668            panic!("expected Accepted");
669        };
670        assert_eq!(accepted, vec![crate::protocol::CAP_NOTIFY]);
671
672        let bytes = crate::protocol::read_frame(&mut client)
673            .unwrap()
674            .expect("hello ack");
675        let msg: crate::protocol::HostMessage = serde_json::from_slice(&bytes).unwrap();
676        match msg {
677            crate::protocol::HostMessage::HelloAck {
678                accepted, rejected, ..
679            } => {
680                assert_eq!(accepted, vec![crate::protocol::CAP_NOTIFY]);
681                assert_eq!(rejected, vec![crate::protocol::CAP_XDG_OPEN]);
682            }
683            other => panic!("expected HelloAck, got {other:?}"),
684        }
685    }
686}