Files
codex/codex-rs/rmcp-client/tests/stdio_descriptor_boundary.rs
jif 595534314f Use descriptor-safe helpers for executable test fixtures (#50018)
## Why

Concurrent Linux test spawns can inherit writable descriptors for executable fixtures, preventing those fixtures from being executed even after the writing test closes its descriptor.

## What changed

Replace direct executable fixture writes and copies across the Rust tests with `codex_utils_cargo_bin::write_executable` and `copy_executable`. These existing helpers complete writes and copies in separate processes on Linux.

Remove the empty temporary inode before creating fake bubblewrap executables, and stage tampered bubblewrap bytes in a separate data file before copying them into the executable fixture. Add the required test dependencies.

GitOrigin-RevId: c875c83088731a4933ef1574e8d79d8cecc12f54
2026-10-01 15:57:03 +00:00

190 lines
6.7 KiB
Rust

//! Unix local MCP servers and their descendants must not inherit unrelated
//! parent descriptors, while their explicit stdio transport remains usable.
#![cfg(unix)]
use std::ffi::OsString;
use std::fs;
use std::os::fd::AsRawFd;
use std::os::fd::FromRawFd;
use std::os::fd::OwnedFd;
use std::os::unix::fs::symlink;
use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use codex_rmcp_client::ElicitationAction;
use codex_rmcp_client::ElicitationResponse;
use codex_rmcp_client::LocalStdioServerLauncher;
use codex_rmcp_client::RmcpClient;
use futures::FutureExt as _;
use pretty_assertions::assert_eq;
use rmcp::model::ClientCapabilities;
use rmcp::model::Implementation;
use rmcp::model::InitializeRequestParams;
use rmcp::model::ProtocolVersion;
use serde_json::json;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn local_stdio_excludes_inheritable_fds_from_server_and_descendant() -> Result<()> {
let temporary = tempfile::tempdir()?;
let script = temporary.path().join("server.py");
fs::write(
&script,
r#"import errno, json, os, signal, subprocess, sys
signal.alarm(30)
sentinel = int(sys.argv[2])
def probe():
for fd in (0, 1, 2):
os.fstat(fd)
print("descriptor fixture stderr", file=sys.stderr, flush=True)
try:
os.fstat(sentinel)
return True
except OSError as error:
if error.errno != errno.EBADF:
raise
return False
if sys.argv[1] == "probe":
print(json.dumps(probe()), flush=True)
sys.exit(0)
for line in sys.stdin:
message = json.loads(line)
method = message.get("method")
if method == "initialize":
result = {"protocolVersion": "2025-06-18", "capabilities": {"tools": {}}, "serverInfo": {"name": "descriptor-fixture", "version": "1"}}
elif method == "tools/call":
# Python normally closes extra descriptors itself. Disable that cleanup
# so the descendant actually exercises the MCP launch boundary.
child = subprocess.run([sys.executable, __file__, "probe", sys.argv[2]], close_fds=False, capture_output=True, text=True, check=True, timeout=5)
result = {"content": [], "structuredContent": {
"server": probe(), "descendant": json.loads(child.stdout),
"descendantStderr": child.stderr}}
else:
continue
print(json.dumps({"jsonrpc": "2.0", "id": message["id"], "result": result}), flush=True)
"#,
)?;
let file = tempfile::tempfile()?;
// F_DUPFD reserves an unused inheritable descriptor without overwriting
// a descriptor owned by the test runner/runtime.
let raw = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_DUPFD, 200) };
if raw < 0 {
return Err(std::io::Error::last_os_error().into());
}
// SAFETY: fcntl returned a new descriptor owned only by this test.
let sentinel = unsafe { OwnedFd::from_raw_fd(raw) };
let sentinel_arg = raw.to_string();
let python = which::which("python3")?;
// Qualify the oracle: the same executable must see the sentinel when
// spawned without the local MCP descriptor policy.
let control = Command::new(&python)
.arg(&script)
.arg("probe")
.arg(&sentinel_arg)
.output()?;
assert!(control.status.success());
assert!(serde_json::from_slice::<bool>(&control.stdout)?);
symlink(&python, temporary.path().join("python-fixture"))?;
let wrapper = temporary.path().join("executable-text");
// No shebang: exercise Command's shell fallback after native macOS spawn
// returns ENOEXEC, including the fallback descriptor cleanup.
codex_utils_cargo_bin::write_executable(&wrapper, "exec \"$@\"\n")?;
for (launch, program, mut args) in [
("absolute", python.clone().into_os_string(), Vec::new()),
("relative", OsString::from("./python-fixture"), Vec::new()),
(
"shell fallback",
OsString::from("./executable-text"),
vec![python.clone().into_os_string()],
),
] {
args.extend([
script.clone().into_os_string(),
"server".into(),
sentinel_arg.clone().into(),
]);
let client = RmcpClient::new_stdio_client(
program,
args,
/*env*/ None,
&[],
Some(temporary.path().to_string_lossy().into_owned()),
Arc::new(LocalStdioServerLauncher::new(std::env::current_dir()?)),
)
.await?;
client
.initialize(
InitializeRequestParams::new(
ClientCapabilities::default(),
Implementation::new("descriptor-boundary-test", "1"),
)
.with_protocol_version(ProtocolVersion::V_2025_06_18),
Some(Duration::from_secs(/*secs*/ 5)),
Box::new(|_, _| {
async {
Ok(ElicitationResponse {
action: ElicitationAction::Decline,
content: None,
meta: None,
})
}
.boxed()
}),
)
.await?;
let result = client
.call_tool(
"probe".to_string(),
/*arguments*/ None,
/*meta*/ None,
Some(Duration::from_secs(/*secs*/ 5)),
)
.await?;
client.shutdown().await;
assert_eq!(
result.structured_content,
Some(json!({
"server": false,
"descendant": false,
"descendantStderr": "descriptor fixture stderr\n"
})),
"{launch} launch"
);
}
// Cleanup must not close or change the parent's descriptor.
assert_eq!(
unsafe { libc::fcntl(sentinel.as_raw_fd(), libc::F_GETFD) },
0
);
Ok(())
}
#[tokio::test]
async fn local_stdio_preserves_exec_failure_reporting() -> Result<()> {
let temporary = tempfile::tempdir()?;
let script = temporary.path().join("missing-interpreter");
let interpreter = temporary.path().join("does-not-exist");
codex_utils_cargo_bin::write_executable(&script, &format!("#!{}\n", interpreter.display()))?;
// Program resolution succeeds, but exec fails. Descriptor cleanup must
// preserve the internal error pipe so launch reports the failure directly.
let result = RmcpClient::new_stdio_client(
script.into_os_string(),
Vec::new(),
/*env*/ None,
&[],
/*cwd*/ None,
Arc::new(LocalStdioServerLauncher::new(std::env::current_dir()?)),
)
.await;
assert!(
result.is_err(),
"exec failure was reported as a successful launch"
);
Ok(())
}