mirror of
https://github.com/openai/codex.git
synced 2026-09-20 12:47:38 +00:00
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
This commit is contained in:
@@ -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::<u64>().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(
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<bool> {
|
||||
let preflight_argv = build_preflight_bwrap_argv(network_mode)?;
|
||||
fn preflight_proc_mount_support(options: BwrapOptions) -> CodexResult<bool> {
|
||||
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<crate::bwrap::BwrapArgs> {
|
||||
fn build_preflight_bwrap_argv(options: BwrapOptions) -> CodexResult<crate::bwrap::BwrapArgs> {
|
||||
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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user