Files
codex/codex-rs/cli/tests/sandbox_tty.rs
jif 51d4562070 Support close-on-exec attachments without changing PTY semantics (#47797)
## Why

Passing extra descriptors to a PTY previously selected behavior intended for inherited escalation sockets: no EOF on stdin close, different signal exit statuses, and no default `SHELL`. Launch attachments need ordinary PTY behavior and must survive execution even when marked `FD_CLOEXEC`.

## What changed

- Add `ChildFds::Attached` alongside `ChildFds::Inherited` to distinguish launch attachments from legacy inherited descriptors.
- Preserve ordinary EOF, default `SHELL`, and signal exit status behavior for PTY attachments.
- Allow explicitly supplied close-on-exec descriptors through pipe and PTY launches without changing the parent's descriptor flags, using child-side flag updates and macOS spawn inheritance actions.
- Update existing callers to use `ChildFds::Inherited`.

## Testing

Add Unix coverage for attachment accessibility, unchanged parent descriptor flags, default `SHELL`, and signal exit statuses. Extend the PTY stdin-draining test to cover attachments and EOF delivery.

GitOrigin-RevId: 864aedc6c5717d8b43c9d71057c9f54409834509
2026-09-24 09:25:19 +00:00

133 lines
4.0 KiB
Rust

//! Exercises the public macOS sandbox runner with a disposable controlling terminal.
#![cfg(target_os = "macos")]
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
use std::time::Duration;
use anyhow::Context as _;
use codex_utils_pty::SpawnedProcess;
use codex_utils_pty::TerminalSize;
use codex_utils_pty::spawn_pty_process;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
const PROBE: &str = r#"
import errno, fcntl, os, select, signal, struct, sys, termios, tty
signal.alarm(15)
assert os.isatty(0)
assert os.tcgetpgrp(0) == os.getpgrp()
assert struct.unpack("HHHH", fcntl.ioctl(0, termios.TIOCGWINSZ, b"\0" * 8))[:2] == (24, 80)
original = termios.tcgetattr(0)
try:
tty.setraw(0)
print("__ready__", flush=True)
assert os.read(0, 1) == b"k"
try:
fcntl.ioctl(0, termios.TIOCSTI, b"x")
except OSError as error:
assert sys.argv[1] == "deny", error
assert error.errno == errno.EPERM, error
assert not select.select([0], [], [], 0)[0]
else:
assert os.read(0, 1) == b"x"
assert sys.argv[1] == "allow", "TIOCSTI unexpectedly succeeded"
finally:
termios.tcsetattr(0, termios.TCSANOW, original)
print("__passed__", flush=True)
"#;
#[tokio::test]
async fn sandbox_blocks_terminal_input_injection() -> anyhow::Result<()> {
let availability = Command::new("/usr/bin/sandbox-exec")
.args(["-p", "(version 1)(allow default)", "/usr/bin/true"])
.output()?;
if !availability.status.success()
&& String::from_utf8_lossy(&availability.stderr)
.contains("sandbox-exec: sandbox_apply: Operation not permitted")
{
eprintln!("skipping terminal injection test: nested Seatbelt is unavailable");
return Ok(());
}
assert!(availability.status.success(), "{availability:?}");
let codex_home = TempDir::new()?;
let mut env: HashMap<String, String> = std::env::vars().collect();
env.insert(
"CODEX_HOME".to_string(),
codex_home.path().to_string_lossy().into_owned(),
);
// The control proves this is a readable, foreground controlling terminal on
// which TIOCSTI would otherwise work. All injected input stays in this PTY.
run_probe(
"/usr/bin/python3",
vec!["-c".to_string(), PROBE.to_string(), "allow".to_string()],
codex_home.path(),
&env,
)
.await?;
let codex = codex_utils_cargo_bin::cargo_bin("codex")?;
run_probe(
&codex.to_string_lossy(),
vec![
"sandbox".to_string(),
"-P".to_string(),
":read-only".to_string(),
"--".to_string(),
"/usr/bin/python3".to_string(),
"-c".to_string(),
PROBE.to_string(),
"deny".to_string(),
],
codex_home.path(),
&env,
)
.await
}
async fn run_probe(
program: &str,
args: Vec<String>,
cwd: &Path,
env: &HashMap<String, String>,
) -> anyhow::Result<()> {
let SpawnedProcess {
session,
mut stdout_rx,
exit_rx,
..
} = spawn_pty_process(
program,
&args,
cwd,
env,
/*arg0*/ &None,
TerminalSize::default(),
codex_utils_pty::ChildFds::Inherited(&[]),
)
.await?;
let mut output = Vec::new();
let mut sent_input = false;
let code = tokio::time::timeout(Duration::from_secs(30), async {
while let Some(bytes) = stdout_rx.recv().await {
output.extend_from_slice(&bytes);
if !sent_input && String::from_utf8_lossy(&output).contains("__ready__") {
session.writer_sender().send(b"k".to_vec()).await?;
sent_input = true;
}
}
Ok::<_, anyhow::Error>(exit_rx.await?)
})
.await
.context("terminal probe timed out")??;
let output = String::from_utf8_lossy(&output);
assert_eq!(code, 0, "terminal probe failed: {output}");
assert!(output.contains("__passed__"), "{output}");
Ok(())
}