From 1bd1bfa7ca4cd15f0dbf5efb8a28b9142c961d37 Mon Sep 17 00:00:00 2001 From: open-matt Date: Thu, 17 Sep 2026 05:06:30 +0000 Subject: [PATCH] Fix daemon socket isolation checks for private tmp mounts (#46125) ## Why Private `/tmp` bind mounts can leave hidden entries in `/proc/self/mountinfo`, causing the Linux sandbox to reject layouts that safely isolate daemon sockets. The proc-mount preflight also needs the main sandbox's WSL masks when checking aliases. ## What changed - Identify the opened socket directory's mount using `fdinfo`, with a `statx` fallback, and follow its mount ancestry to distinguish hidden paths from exposed aliases. - Accept safe private and stacked mounts while rejecting covered mounts, exposed aliases, and nested mounts. Retain conservative checks when no mount ID is available. - Preserve WSL interop and WSLg masks in the proc-mount preflight. - Heap-allocate large lifecycle futures in the TUI approval-gated MCP tool test to reduce Windows test-thread stack usage. ## Testing Add mount-layout regression cases and a namespace integration test that verifies daemon sockets remain inaccessible under private `/tmp`, unrelated sockets remain reachable, and an exposed alias prevents startup. Add a preflight test for preserving WSL masks. GitOrigin-RevId: 289b4446ba7776a7dd57ef5ff09e87bda19b069c --- codex-rs/linux-sandbox/src/daemon_mounts.rs | 105 ++++++++++++--- .../linux-sandbox/src/daemon_mounts_tests.rs | 127 +++++++++++++++--- codex-rs/linux-sandbox/src/linux_run_main.rs | 14 +- .../linux-sandbox/src/linux_run_main_tests.rs | 39 +++++- .../tests/suite/daemon_sockets_tests.rs | 123 +++++++++++++++++ .../app/tests/session_lifecycle_requests.rs | 51 ++++--- 6 files changed, 386 insertions(+), 73 deletions(-) diff --git a/codex-rs/linux-sandbox/src/daemon_mounts.rs b/codex-rs/linux-sandbox/src/daemon_mounts.rs index 713688b59c..50a65636b4 100644 --- a/codex-rs/linux-sandbox/src/daemon_mounts.rs +++ b/codex-rs/linux-sandbox/src/daemon_mounts.rs @@ -1,9 +1,13 @@ //! Reject host mount aliases that would bypass the privileged socket directory mask. //! Mount roots describe filesystem identity; canonical paths alone miss bind mounts. +use rustix::fs::AtFlags; +use rustix::fs::StatxFlags; +use rustix::fs::statx; use std::collections::BTreeSet; use std::fs; use std::io; +use std::os::fd::AsRawFd; use std::os::unix::ffi::OsStringExt; use std::os::unix::fs::MetadataExt; use std::path::Path; @@ -13,10 +17,29 @@ pub(crate) fn reject_daemon_mount_aliases( directory: &Path, masked_root: Option<&Path>, ) -> io::Result<()> { - let device = fs::metadata(directory)?.dev(); + let directory_file = fs::File::open(directory)?; + let device = directory_file.metadata()?.dev(); + let mount_id = fs::read_to_string(format!("/proc/self/fdinfo/{}", directory_file.as_raw_fd())) + .ok() + .and_then(|fdinfo| { + fdinfo + .lines() + .find_map(|line| line.strip_prefix("mnt_id:")) + .and_then(|id| id.trim().parse::().ok()) + }) + .or_else(|| { + // Query the same open directory, using the ID shared with mountinfo. + // Older kernels may succeed without returning the requested field. + statx(&directory_file, "", AtFlags::EMPTY_PATH, StatxFlags::MNT_ID) + .ok() + .filter(|stat| stat.stx_mask & StatxFlags::MNT_ID.bits() != 0) + .map(|stat| stat.stx_mnt_id) + }) + .map(|id| id.to_string()); check_mounts( directory, &format!("{}:{}", libc::major(device), libc::minor(device)), + mount_id.as_deref(), &fs::read("/proc/self/mountinfo")?, masked_root, ) @@ -25,36 +48,78 @@ pub(crate) fn reject_daemon_mount_aliases( fn check_mounts( directory: &Path, device: &str, + mount_id: Option<&str>, mountinfo: &[u8], masked_root: Option<&Path>, ) -> io::Result<()> { let invalid = || io::Error::other("cannot establish app-server socket mount isolation"); let mut mounts = Vec::new(); - let mut locations = BTreeSet::new(); for line in mountinfo .split(|byte| *byte == b'\n') .filter(|line| !line.is_empty()) { let fields: Vec<_> = line.split(|byte| *byte == b' ').take(5).collect(); - let [_, _, mount_device, root, destination] = fields.as_slice() else { + let [id, parent, mount_device, root, destination] = fields.as_slice() else { return Err(invalid()); }; let root = mount_path(root)?; let destination = mount_path(destination)?; - if *mount_device == device.as_bytes() - && let Ok(relative) = directory.strip_prefix(&destination) - { - locations.insert(root.join(relative)); + mounts.push((*id, *parent, *mount_device, root, destination)); + } + let (location, containing_mount) = if let Some(mount_id) = mount_id { + // fdinfo/statx identifies the opened mount, which may have been covered + // by another mount before we read mountinfo. + let selected = mounts + .iter() + .find(|(id, ..)| *id == mount_id.as_bytes()) + .ok_or_else(invalid)?; + let (_, _, mount_device, root, destination) = selected; + if *mount_device != device.as_bytes() { + return Err(invalid()); } - mounts.push((*mount_device, root, destination)); - } - // Overmounts can leave hidden entries in mountinfo. Require every possible - // containing mount to agree instead of guessing which root is visible. - if locations.len() != 1 { - return Err(invalid()); - } - let location = locations.into_iter().next().ok_or_else(invalid)?; - for (mount_device, root, destination) in &mounts { + let relative = directory.strip_prefix(destination).map_err(|_| invalid())?; + let mut current = Some(selected); + let mut visible_child: Option<&Path> = None; + let mut visited = BTreeSet::new(); + while let Some((id, parent, _, _, destination)) = current { + if !visited.insert(id) + || mounts.iter().any(|(child_id, child_parent, _, _, child)| { + child_id != id + && child_parent == id + && directory.starts_with(child) + && !visible_child.is_some_and(|visible| child.starts_with(visible)) + }) + { + return Err(invalid()); + } + if id == parent { + break; + } + // Follow the selected branch towards the namespace root. Sibling + // mounts below this branch are hidden; mounts above it cover it. + visible_child = Some(destination); + current = mounts.iter().find(|(id, ..)| id == parent); + } + (root.join(relative), Some((mount_id, destination))) + } else { + // Without a mount ID, require every possible containing mount to agree + // on the backing location, and do not assume any aliases are hidden. + let locations: BTreeSet<_> = mounts + .iter() + .filter(|(_, _, mount_device, ..)| *mount_device == device.as_bytes()) + .filter_map(|(_, _, _, root, destination)| { + directory + .strip_prefix(destination) + .ok() + .map(|relative| root.join(relative)) + }) + .collect(); + if locations.len() != 1 { + return Err(invalid()); + } + (locations.into_iter().next().ok_or_else(invalid)?, None) + }; + for (id, _, mount_device, root, destination) in &mounts { // Nested mounts can introduce another filesystem (or an individual socket) under the mask. let nested = destination != directory && destination.starts_with(directory); let alias = if *mount_device == device.as_bytes() { @@ -70,8 +135,16 @@ fn check_mounts( }; if nested || alias.is_some_and(|path| { + // An ancestor's path beneath this mount is hidden by it. Keep + // checking other mounts, including aliases mounted beneath it. + let hidden = containing_mount.is_some_and(|(mount_id, containing_mount)| { + *id != mount_id.as_bytes() + && containing_mount.starts_with(destination) + && path.starts_with(containing_mount) + }); !path.starts_with(directory) && !masked_root.is_some_and(|root| path.starts_with(root)) + && !hidden }) { return Err(io::Error::new( diff --git a/codex-rs/linux-sandbox/src/daemon_mounts_tests.rs b/codex-rs/linux-sandbox/src/daemon_mounts_tests.rs index 04e0fe5d25..058ed2b2e0 100644 --- a/codex-rs/linux-sandbox/src/daemon_mounts_tests.rs +++ b/codex-rs/linux-sandbox/src/daemon_mounts_tests.rs @@ -3,8 +3,15 @@ use pretty_assertions::assert_eq; use test_case::test_case; // Most cases have no independently masked subtree. -fn check_mounts(directory: &Path, device: &str, mountinfo: &[u8]) -> io::Result<()> { - super::check_mounts(directory, device, mountinfo, /*masked_root*/ None) +fn check_mounts( + directory: &Path, + device: &str, + mount_id: Option<&str>, + mountinfo: &[u8], +) -> io::Result<()> { + super::check_mounts( + directory, device, mount_id, mountinfo, /*masked_root*/ None, + ) } #[test_case("/tmp", "/host-tmp", false; "ancestor alias")] @@ -18,37 +25,117 @@ fn check_mounts(directory: &Path, device: &str, mountinfo: &[u8]) -> io::Result< fn rejects_only_mounts_that_compromise_the_directory(root: &str, destination: &str, allowed: bool) { let mounts = format!("1 0 0:1 / / rw - ext4 disk rw\n2 1 0:1 {root} {destination} rw - ext4 disk rw\n"); - assert_eq!( - check_mounts( - Path::new("/tmp/codex-daemon-1000"), - "0:1", - mounts.as_bytes() - ) - .is_ok(), - allowed - ); + let visible_mount = if destination == "/tmp" { "2" } else { "1" }; + for mount_id in [Some(visible_mount), None] { + assert_eq!( + check_mounts( + Path::new("/tmp/codex-daemon-1000"), + "0:1", + mount_id, + mounts.as_bytes() + ) + .is_ok(), + allowed, + "mount_id: {mount_id:?}" + ); + } } #[test] fn rejects_alias_when_tmp_is_itself_a_bind_mount() { let mounts = b"1 0 0:1 / / rw - ext4 disk rw\n2 1 0:1 /backing/tmp /tmp rw - ext4 disk rw\n"; - assert!(check_mounts(Path::new("/tmp/codex-daemon-1000"), "0:1", mounts).is_err()); + assert!( + check_mounts( + Path::new("/tmp/codex-daemon-1000"), + "0:1", + Some("2"), + mounts + ) + .is_err() + ); // A hidden deeper mount must not override the actual /tmp backing location. let hidden = [ mounts.as_slice(), b"3 1 0:1 /tmp/codex-daemon-1000 /tmp/codex-daemon-1000 rw - ext4 disk rw\n", ] .concat(); - assert!(check_mounts(Path::new("/tmp/codex-daemon-1000"), "0:1", &hidden).is_err()); + assert!( + check_mounts( + Path::new("/tmp/codex-daemon-1000"), + "0:1", + Some("2"), + &hidden + ) + .is_err() + ); } #[test] -fn accepts_private_tmp_filesystem_but_rejects_ambiguous_stacked_mounts() { +fn accepts_private_tmp_filesystem_and_resolves_stacked_mounts() { let mounts = "1 0 0:1 / / rw - ext4 disk rw\n2 1 0:2 / /tmp rw - tmpfs tmpfs rw\n"; let directory = Path::new("/tmp/codex-daemon-1000"); - assert!(check_mounts(directory, "0:2", mounts.as_bytes()).is_ok()); + assert!(check_mounts(directory, "0:2", Some("2"), mounts.as_bytes()).is_ok()); + assert!(check_mounts(directory, "0:2", /*mount_id*/ None, mounts.as_bytes()).is_ok()); + // Missing or inconsistent precise IDs must not fall back to the otherwise + // acceptable conservative interpretation. + assert!(check_mounts(directory, "0:2", Some("missing"), mounts.as_bytes()).is_err()); + assert!(check_mounts(directory, "0:2", Some("1"), mounts.as_bytes()).is_err()); + assert!(check_mounts(directory, "0:3", /*mount_id*/ None, mounts.as_bytes()).is_err()); let stacked = format!("{mounts}3 2 0:2 /other /tmp rw - tmpfs tmpfs rw\n"); - assert!(check_mounts(directory, "0:2", stacked.as_bytes()).is_err()); + assert!(check_mounts(directory, "0:2", Some("3"), stacked.as_bytes()).is_ok()); + assert!(check_mounts(directory, "0:2", /*mount_id*/ None, stacked.as_bytes()).is_err()); +} + +#[test] +fn rejects_open_mount_that_has_been_covered() { + let directory = Path::new("/tmp/codex-daemon-1000"); + let mounts = "1 0 0:1 / / rw - ext4 disk rw\n\ + 2 1 0:1 /tmp/private-old/tmp /tmp rw - ext4 disk rw\n\ + 3 2 0:1 /tmp/private-new/tmp /tmp rw - ext4 disk rw\n"; + assert!(check_mounts(directory, "0:1", Some("2"), mounts.as_bytes()).is_err()); + assert!(check_mounts(directory, "0:1", Some("3"), mounts.as_bytes()).is_ok()); + let exposed = format!( + "{mounts}4 1 0:1 /tmp/private-new/tmp/codex-daemon-1000 /outside rw - ext4 disk rw\n" + ); + for mount_id in [Some("2"), Some("3"), None] { + assert!(check_mounts(directory, "0:1", mount_id, exposed.as_bytes()).is_err()); + } +} + +#[test] +fn rejects_mount_hidden_by_an_ancestor_overmount() { + let directory = Path::new("/tmp/private/codex-daemon-1000"); + let mounts = "1 0 0:1 / / rw - ext4 disk rw\n\ + 2 1 0:2 / /tmp rw - tmpfs tmpfs rw\n\ + 3 2 0:3 / /tmp/private rw - tmpfs tmpfs rw\n"; + assert!(check_mounts(directory, "0:3", Some("3"), mounts.as_bytes()).is_ok()); + let covered = format!("{mounts}4 2 0:2 /other /tmp rw - tmpfs tmpfs rw\n"); + assert!(check_mounts(directory, "0:3", Some("3"), covered.as_bytes()).is_err()); + assert!(check_mounts(directory, "0:2", Some("4"), covered.as_bytes()).is_ok()); +} + +#[test_case("/tmp/systemd-private-service/tmp", "/"; "tmp on root filesystem")] +#[test_case("/systemd-private-service/tmp", "/tmp"; "tmp on separate filesystem")] +fn accepts_private_tmp_bind_but_rejects_exposed_aliases(root: &str, parent: &str) { + let directory = Path::new("/tmp/codex-daemon-1000"); + let mounts = + format!("1 0 0:1 / {parent} rw - ext4 disk rw\n2 1 0:1 {root} /tmp rw - ext4 disk rw\n"); + assert!(check_mounts(directory, "0:1", Some("2"), mounts.as_bytes()).is_ok()); + // PrivateTmp remains ambiguous when neither fdinfo nor statx supplies an ID. + assert!(check_mounts(directory, "0:1", /*mount_id*/ None, mounts.as_bytes()).is_err()); + + // A real alias beneath /tmp is not hidden by the private /tmp mount. + for (alias_root, destination) in [ + (root.to_owned(), "/tmp/exposed"), + ( + format!("{root}/codex-daemon-1000/rpc.sock"), + "/tmp/alias.sock", + ), + ("/".to_owned(), "/host"), + ] { + let exposed = format!("{mounts}3 2 0:1 {alias_root} {destination} rw - ext4 disk rw\n"); + assert!(check_mounts(directory, "0:1", Some("2"), exposed.as_bytes()).is_err()); + } } #[test] @@ -56,8 +143,10 @@ fn masked_wslg_alias_does_not_allow_other_exposed_aliases() { let mounts = "1 0 0:1 / / rw - ext4 disk rw\n2 1 0:1 / /mnt/wslg/distro rw - ext4 disk rw\n"; let directory = Path::new("/tmp/codex-daemon-1000"); let mask = Some(Path::new(crate::bwrap::WSLG_DISTRO_ROOT)); - assert!(check_mounts(directory, "0:1", mounts.as_bytes()).is_err()); - assert!(super::check_mounts(directory, "0:1", mounts.as_bytes(), mask).is_ok()); let exposed = format!("{mounts}3 1 0:1 /tmp /host-tmp rw - ext4 disk rw\n"); - assert!(super::check_mounts(directory, "0:1", exposed.as_bytes(), mask).is_err()); + for mount_id in [Some("1"), None] { + assert!(check_mounts(directory, "0:1", mount_id, mounts.as_bytes()).is_err()); + assert!(super::check_mounts(directory, "0:1", mount_id, mounts.as_bytes(), mask).is_ok()); + assert!(super::check_mounts(directory, "0:1", mount_id, exposed.as_bytes(), mask).is_err()); + } } diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index 1f22911aed..480de987c5 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -422,7 +422,7 @@ fn run_bwrap_with_proc_fallback( let command_cwd = command_cwd.unwrap_or(sandbox_policy_cwd); if options.mount_proc - && !preflight_proc_mount_support(options.network_mode) + && !preflight_proc_mount_support(options) .unwrap_or_else(|err| exit_with_bwrap_build_error(err)) { // Keep the retry silent so sandbox-internal diagnostics do not leak into the @@ -526,15 +526,13 @@ fn current_process_argv0() -> String { } } -fn preflight_proc_mount_support(network_mode: BwrapNetworkMode) -> CodexResult { - let preflight_argv = build_preflight_bwrap_argv(network_mode)?; +fn preflight_proc_mount_support(options: BwrapOptions) -> CodexResult { + let preflight_argv = build_preflight_bwrap_argv(options)?; let stderr = run_bwrap_in_child_capture_stderr(preflight_argv); Ok(!is_proc_mount_failure(stderr.as_str())) } -fn build_preflight_bwrap_argv( - network_mode: BwrapNetworkMode, -) -> CodexResult { +fn build_preflight_bwrap_argv(options: BwrapOptions) -> CodexResult { let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -551,8 +549,8 @@ fn build_preflight_bwrap_argv( Path::new("/"), BwrapOptions { mount_proc: true, - network_mode, - ..Default::default() + // The alias check must see the same WSL masks as the main sandbox. + ..options }, ) } diff --git a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs index 0f9291d5ce..583e7b8e8b 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs @@ -326,16 +326,19 @@ fn managed_proxy_preflight_argv_unshares_network() { NetworkSandboxPolicy::Enabled, /*allow_network_for_proxy*/ true, ); - let argv = build_preflight_bwrap_argv(mode) - .expect("build preflight argv") - .args; + let argv = build_preflight_bwrap_argv(BwrapOptions { + network_mode: mode, + ..Default::default() + }) + .expect("build preflight argv") + .args; assert!(argv.iter().any(|arg| arg == "--")); assert!(argv.iter().any(|arg| arg == "--unshare-net")); } #[test] fn proc_mount_preflight_does_not_bind_the_full_filesystem() { - let argv = build_preflight_bwrap_argv(BwrapNetworkMode::FullAccess) + let argv = build_preflight_bwrap_argv(BwrapOptions::default()) .expect("build preflight argv") .args; @@ -349,6 +352,34 @@ fn proc_mount_preflight_does_not_bind_the_full_filesystem() { assert!(!argv.windows(3).any(|window| window == ["--bind", "/", "/"])); } +#[test] +fn proc_mount_preflight_preserves_wsl_masks() { + let argv = build_preflight_bwrap_argv(BwrapOptions { + mask_wsl_interop: true, + mask_wslg_distro: true, + ..Default::default() + }) + .expect("build WSL preflight argv") + .args; + + assert!(argv.windows(2).any(|window| window == ["--proc", "/proc"])); + assert!( + argv.windows(2) + .any(|window| window == ["--tmpfs", WSL_INTEROP_DIR]) + ); + assert!(argv.windows(6).any(|window| { + window + == [ + "--perms", + "000", + "--tmpfs", + WSLG_DISTRO_ROOT, + "--remount-ro", + WSLG_DISTRO_ROOT, + ] + })); +} + #[test] fn cleanup_synthetic_mount_targets_removes_only_empty_mount_targets() { let temp_dir = tempfile::TempDir::new().expect("tempdir"); diff --git a/codex-rs/linux-sandbox/tests/suite/daemon_sockets_tests.rs b/codex-rs/linux-sandbox/tests/suite/daemon_sockets_tests.rs index 4eba019b51..30889d0f9d 100644 --- a/codex-rs/linux-sandbox/tests/suite/daemon_sockets_tests.rs +++ b/codex-rs/linux-sandbox/tests/suite/daemon_sockets_tests.rs @@ -4,6 +4,129 @@ use std::os::fd::AsRawFd; use std::os::unix::net::UnixListener; use std::os::unix::net::UnixStream; +#[tokio::test] +async fn private_tmp_mount_preserves_daemon_socket_isolation() { + if should_skip_bwrap_tests().await { + return; + } + // The parent owns cleanup after the child's disposable mount namespace exits. + let private = tempfile::tempdir_in("/tmp").unwrap(); + let test_executable = std::env::current_exe().unwrap(); + // Keep both executables available after the original /tmp is hidden. + std::fs::copy(&test_executable, private.path().join("test")).unwrap(); + std::fs::copy(codex_linux_sandbox_exe(), private.path().join("sandbox")).unwrap(); + let (_, test_module) = module_path!().split_once("::").unwrap(); + let fixture_test = format!("{test_module}::private_tmp_fixture"); + // Mount setup needs capabilities, which bubblewrap only accepts as namespace root. + let output = tokio::process::Command::new("unshare") + .args([ + "--user", + "--map-root-user", + "--mount", + "--propagation", + "private", + "--", + ]) + .arg(test_executable) + .args([ + "--exact", + &fixture_test, + "--ignored", + "--nocapture", + "--test-threads=1", + ]) + .env("CODEX_TEST_PRIVATE_TMP", private.path()) + .kill_on_drop(true) + .output() + .await + .unwrap(); + if output.status.code() == Some(77) + || String::from_utf8_lossy(&output.stderr).starts_with("unshare:") + { + eprintln!("skipping private tmp test: user/mount namespaces are unavailable"); + return; + } + assert_eq!(output.status.code(), Some(0), "{output:?}"); + assert!(String::from_utf8_lossy(&output.stdout).contains("private-tmp-validated")); +} + +#[test] +#[ignore = "invoked inside a disposable mount namespace"] +fn private_tmp_fixture() { + let Some(private) = std::env::var_os("CODEX_TEST_PRIVATE_TMP") else { + return; + }; + let mount = std::process::Command::new("mount") + .arg("--bind") + .arg(private) + .arg("/tmp") + .output() + .unwrap(); + if !mount.status.success() { + std::process::exit(/*code*/ 77); + } + let root = codex_uds::prepare_shared_daemon_socket_directory().unwrap(); + let endpoint = root.join("rpc.sock"); + let _daemon = UnixListener::bind(&endpoint).unwrap(); + let _other = UnixListener::bind("/tmp/other.sock").unwrap(); + UnixStream::connect(&endpoint).expect("host can reach daemon"); + let profile = PermissionProfile::workspace_write_with( + &[AbsolutePathBuf::from_absolute_path("/tmp").unwrap()], + NetworkSandboxPolicy::Enabled, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ false, + ); + let mut command = std::process::Command::new("/tmp/sandbox"); + command + .args(["--sandbox-policy-cwd", "/tmp", "--permission-profile"]) + .arg(serde_json::to_string(&profile).unwrap()) + .arg("--") + .current_dir("/tmp"); + let (_, test_module) = module_path!().split_once("::").unwrap(); + let client_test = format!("{test_module}::private_tmp_client"); + command + .args([ + "/tmp/test", + "--exact", + &client_test, + "--ignored", + "--nocapture", + "--test-threads=1", + ]) + .env("CODEX_TEST_DAEMON_SOCKET", &endpoint); + let output = command.output().unwrap(); + assert_eq!(output.status.code(), Some(0), "{output:?}"); + assert!(String::from_utf8_lossy(&output.stdout).contains("private-tmp-isolated")); + + // A real alias below the private mount must still prevent startup. + std::fs::create_dir("/tmp/alias").unwrap(); + let mount = std::process::Command::new("mount") + .arg("--bind") + .arg(&root) + .arg("/tmp/alias") + .output() + .unwrap(); + assert!(mount.status.success(), "{mount:?}"); + UnixStream::connect("/tmp/alias/rpc.sock").expect("host can reach alias"); + let output = command.output().unwrap(); + assert!(!output.status.success(), "{output:?}"); + assert!(String::from_utf8_lossy(&output.stderr).contains("unsupported host mount")); + assert!(!String::from_utf8_lossy(&output.stdout).contains("private-tmp-client-started")); + println!("private-tmp-validated"); +} + +#[test] +#[ignore = "invoked inside the private tmp sandbox"] +fn private_tmp_client() { + let Some(endpoint) = std::env::var_os("CODEX_TEST_DAEMON_SOCKET") else { + return; + }; + println!("private-tmp-client-started"); + assert!(UnixStream::connect(endpoint).is_err(), "daemon reachable"); + UnixStream::connect("/tmp/other.sock").expect("unrelated socket reachable"); + println!("private-tmp-isolated"); +} + #[tokio::test] async fn daemon_socket_bind_mount_alias_rejects_sandbox_startup() { if should_skip_bwrap_tests().await { diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index aad1b59261..53fcfb4de5 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -1091,11 +1091,12 @@ async fn local_daemon_registers_approval_gated_mcp_tools_for_both_start_paths() codex_home.path().join("config.toml"), "web_search = \"disabled\"\n", )?; - let (mut app_server, mut requests, mut proxy) = start_recording_app_server( + // Keep the large lifecycle futures off the Windows test thread's stack. + let (mut app_server, mut requests, mut proxy) = Box::pin(start_recording_app_server( &app.config, /*blocked_thread_list*/ None, /*failed_thread_name*/ None, - ) + )) .await?; app_server .start_dynamic_tool_mcp( @@ -1190,14 +1191,13 @@ async fn local_daemon_registers_approval_gated_mcp_tools_for_both_start_paths() ThreadHistoryMode::Legacy, "Approved task source", )?; - app_server - .resume_thread( - &app.local_settings, - app.config.clone(), - delegation_source, - crate::app_server_session::ResumeModelSettings::RestoreFromThread, - ) - .await?; + Box::pin(app_server.resume_thread( + &app.local_settings, + app.config.clone(), + delegation_source, + crate::app_server_session::ResumeModelSettings::RestoreFromThread, + )) + .await?; let resumed = recorded_params(&requests, "thread/resume") .pop() .expect("resumed task request"); @@ -1205,14 +1205,13 @@ async fn local_daemon_registers_approval_gated_mcp_tools_for_both_start_paths() resumed["config"]["mcp_servers.codex_tui"], starts[0]["config"]["mcp_servers.codex_tui"] ); - app_server - .resume_thread( - &app.local_settings, - app.config.clone(), - delegation_source, - crate::app_server_session::ResumeModelSettings::PreserveExistingThread, - ) - .await?; + Box::pin(app_server.resume_thread( + &app.local_settings, + app.config.clone(), + delegation_source, + crate::app_server_session::ResumeModelSettings::PreserveExistingThread, + )) + .await?; let reattached = recorded_params(&requests, "thread/resume") .pop() .expect("reattached task request"); @@ -1220,8 +1219,7 @@ async fn local_daemon_registers_approval_gated_mcp_tools_for_both_start_paths() reattached["config"]["mcp_servers.codex_tui"], starts[0]["config"]["mcp_servers.codex_tui"] ); - app_server - .fork_thread(&app.local_settings, app.config.clone(), delegation_source) + Box::pin(app_server.fork_thread(&app.local_settings, app.config.clone(), delegation_source)) .await?; let forked = recorded_params(&requests, "thread/fork") .pop() @@ -1272,12 +1270,13 @@ async fn local_daemon_registers_approval_gated_mcp_tools_for_both_start_paths() paused.contains("TUI is reconnecting; tool was not sent"), "{paused}" ); - let (replacement, replacement_requests, replacement_proxy) = start_recording_app_server( - &app.config, - /*blocked_thread_list*/ None, - /*failed_thread_name*/ None, - ) - .await?; + let (replacement, replacement_requests, replacement_proxy) = + Box::pin(start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + )) + .await?; let (new_tx, new_rx) = mpsc::unbounded_channel(); let new_sender = AppEventSender::new(new_tx); drop(events);