Files
codex/codex-rs/exec-server/src/shell_snapshot_file_tests.rs
T
jif c98e263fb5 Replay exec-server shell snapshots through unnamed files (#48078)
## Why

Large shell snapshots need a replay path that does not add their state to the child process environment.

## What changed

- Keep captured state cached in memory and give each Bash or Zsh launch an independent, read-only file descriptor to source. Unlink the file before writing shell state, create it under the sandbox-protected daemon directory, and close the carrier descriptor before restoring functions.
- Probe descriptor-path access inside the capture sandbox. Retain environment replay for `sh` and when descriptor paths are unavailable; use normal shell startup if preparing the replay file fails.
- Group options and aliases during capture so sourcing preserves their parsing behavior.

## Testing

Add coverage for concurrent replays with 480 KiB payloads, pipe and TTY execution, stdin preservation, descriptor cleanup, sandbox protection, blocked descriptor-path fallback, and alias/option parsing equivalence.

GitOrigin-RevId: 8103d5c37ce4d1885628a4efba3551549f12bea8
2026-09-25 12:23:15 +00:00

75 lines
2.7 KiB
Rust

use super::materialize;
use codex_shell_command::shell_detect::ShellType;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::io::Write;
use std::os::fd::AsRawFd;
use std::os::unix::fs::MetadataExt;
use std::path::Path;
use test_case::test_case;
#[test_case("/bin/bash", ShellType::Bash; "bash")]
#[cfg_attr(target_os = "macos", test_case("/bin/zsh", ShellType::Zsh; "zsh"))]
#[tokio::test]
async fn unnamed_snapshot_preserves_stdin_and_closes_read_only_carrier(
shell: &str,
shell_type: ShellType,
) -> anyhow::Result<()> {
let state = "helper() { printf 'restored:%s' \"$1\"; }\nexec() { exit 42; }\n";
let mut reader = materialize(shell_type, state)?;
let fd = reader.as_raw_fd();
#[cfg(target_os = "linux")]
assert!(
std::fs::read_link(format!("/proc/self/fd/{fd}"))?
.starts_with(codex_uds::shared_daemon_socket_directory()?),
"the transport must be protected even before it is unlinked"
);
let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
assert_eq!(
(
reader.metadata()?.nlink(),
flags & libc::FD_CLOEXEC,
reader.write(b"x").unwrap_err().raw_os_error()
),
(0, libc::FD_CLOEXEC, Some(libc::EBADF)),
);
let closed_check = format!("if [ -e /dev/fd/{fd} ]; then exit 43; fi\n");
let args = vec![
"-c".to_string(),
format!(". /dev/fd/{fd}\n{closed_check}IFS= read -r line; helper \"$line\"; exit 7"),
];
let env = HashMap::from([("PATH".to_string(), "/usr/bin:/bin".to_string())]);
let spawned =
codex_utils_pty::spawn_pipe_process(shell, &args, Path::new("/"), &env, &None, &[fd])
.await?;
assert_eq!(unsafe { libc::fcntl(fd, libc::F_GETFD) }, flags);
drop(reader);
let codex_utils_pty::SpawnedProcess {
session,
mut stdout_rx,
mut stderr_rx,
exit_rx,
} = spawned;
session.writer_sender().send(b"input\n".to_vec()).await?;
let result = tokio::time::timeout(std::time::Duration::from_secs(10), async {
let mut output = Vec::new();
while let Some(chunk) = stdout_rx.recv().await {
output.extend(chunk);
}
let mut errors = Vec::new();
while let Some(chunk) = stderr_rx.recv().await {
errors.extend(chunk);
}
Ok::<_, anyhow::Error>((String::from_utf8(output)?, errors, exit_rx.await?))
})
.await;
session.terminate();
let (output, errors, status) = result??;
assert!(
output.ends_with("restored:input"),
"stdout={output:?}, stderr={errors:?}, status={status}"
);
assert_eq!((errors, status), (Vec::new(), 7));
Ok(())
}