mirror of
https://github.com/openai/codex.git
synced 2026-10-16 16:58:46 +00:00
## 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
175 lines
7.4 KiB
Rust
175 lines
7.4 KiB
Rust
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
use test_case::test_case;
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum SnapshotSandbox {
|
|
None,
|
|
WorkspaceWrite,
|
|
DenyFdPath,
|
|
}
|
|
|
|
#[test_case("bash", false, SnapshotSandbox::None; "bash")]
|
|
#[test_case("bash", true, SnapshotSandbox::None; "bash_tty")]
|
|
#[cfg_attr(target_os = "macos", test_case("zsh", false, SnapshotSandbox::None; "zsh"))]
|
|
#[cfg_attr(target_os = "macos", test_case("zsh", true, SnapshotSandbox::None; "zsh_tty"))]
|
|
#[test_case("bash", false, SnapshotSandbox::DenyFdPath; "blocked_descriptor_path")]
|
|
#[test_case("bash", false, SnapshotSandbox::WorkspaceWrite; "bash_protected_transport")]
|
|
#[test_case("bash", true, SnapshotSandbox::WorkspaceWrite; "bash_protected_transport_tty")]
|
|
#[cfg_attr(target_os = "macos", test_case("zsh", false, SnapshotSandbox::WorkspaceWrite; "zsh_protected_transport"))]
|
|
#[cfg_attr(target_os = "macos", test_case("zsh", true, SnapshotSandbox::WorkspaceWrite; "zsh_protected_transport_tty"))]
|
|
#[serial_test::serial(remote_exec_server)]
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn shell_snapshot_concurrent_replays_keep_independent_readers(
|
|
shell: &str,
|
|
tty: bool,
|
|
sandbox_mode: SnapshotSandbox,
|
|
) -> Result<()> {
|
|
let deny_fd_path = sandbox_mode == SnapshotSandbox::DenyFdPath;
|
|
let use_sandbox = sandbox_mode != SnapshotSandbox::None;
|
|
if use_sandbox
|
|
&& let Some(warning) =
|
|
codex_sandboxing::system_bwrap_warning(&PermissionProfile::read_only())
|
|
{
|
|
eprintln!("skipping sandbox test: {warning}");
|
|
return Ok(());
|
|
}
|
|
let context = if sandbox_mode == SnapshotSandbox::WorkspaceWrite {
|
|
let (exe, linux_sandbox) = current_test_binary_helper_paths()?;
|
|
let environment = Environment::create(
|
|
/*exec_server_url*/ None,
|
|
codex_exec_server::ExecServerRuntimePaths::new(exe, linux_sandbox)?,
|
|
codex_http_client::HttpClientFactory::new(
|
|
codex_http_client::OutboundProxyPolicy::ReqwestDefault,
|
|
),
|
|
)?;
|
|
ProcessContext {
|
|
backend: environment.get_exec_backend(),
|
|
_server: None,
|
|
}
|
|
} else {
|
|
create_process_context(deny_fd_path).await?
|
|
};
|
|
let home = TempDir::new()?;
|
|
let cwd = PathUri::from_host_native_path(home.path())?;
|
|
// Keep full-size state in a function, below the 512 KiB state + environment cap.
|
|
// The blocked-path case still exercises the smaller environment fallback.
|
|
let payload_len = if deny_fd_path { 1 } else { 480 * 1024 };
|
|
let payload = "x".repeat(payload_len);
|
|
let source = if shell == "bash" {
|
|
"${BASH_SOURCE[0]}"
|
|
} else {
|
|
"${(%):-%x}"
|
|
};
|
|
let source_check = if deny_fd_path {
|
|
String::new()
|
|
} else {
|
|
format!("case \"{source}\" in /dev/fd/*) ;; *) return 42 ;; esac; ")
|
|
};
|
|
std::fs::write(
|
|
home.path().join(format!(".{shell}rc")),
|
|
format!(
|
|
"printf x >> \"$HOME/captures\"\nprofile_helper() {{ {source_check}local payload='{payload}'; [ \"${{#payload}}\" = {payload_len} ] || return 43; printf 'restored:%s' \"$1\"; }}\nexec() {{ exit 41; }}\nset -u\n"
|
|
),
|
|
)?;
|
|
let protected_file =
|
|
tempfile::NamedTempFile::new_in(codex_uds::prepare_shared_daemon_socket_directory()?)?;
|
|
std::fs::write(protected_file.path(), "protected")?;
|
|
let command = if sandbox_mode == SnapshotSandbox::WorkspaceWrite {
|
|
// Broad /tmp grants must not expose the file while it still has a name.
|
|
"if /bin/cat \"$SNAPSHOT_TRANSPORT_PROBE\" >/dev/null 2>&1 || (printf poisoned >> \"$SNAPSHOT_TRANSPORT_PROBE\") 2>/dev/null || /bin/mv \"$SNAPSHOT_TRANSPORT_PROBE\" \"$HOME/stolen\" 2>/dev/null; then exit 44; fi\n"
|
|
} else {
|
|
""
|
|
};
|
|
let command = format!("{command}IFS= read -r line; profile_helper \"$line\"; exit 7");
|
|
let sandbox = if use_sandbox {
|
|
let mut policy = FileSystemSandboxPolicy::read_only();
|
|
policy.entries.push(FileSystemSandboxEntry::new(
|
|
cwd.clone().into(),
|
|
FileSystemAccessMode::Write,
|
|
));
|
|
if deny_fd_path {
|
|
policy.entries.push(FileSystemSandboxEntry::new(
|
|
PathUri::from_host_native_path("/dev/fd")?.into(),
|
|
FileSystemAccessMode::Deny,
|
|
));
|
|
} else {
|
|
policy.entries.push(FileSystemSandboxEntry::new(
|
|
PathUri::from_host_native_path(std::fs::canonicalize("/tmp")?)?.into(),
|
|
FileSystemAccessMode::Write,
|
|
));
|
|
}
|
|
Some(FileSystemSandboxContext::from_permission_profile(
|
|
PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted),
|
|
cwd.clone(),
|
|
))
|
|
} else {
|
|
None
|
|
};
|
|
let commands = (0..8).map(|index| {
|
|
let backend = &context.backend;
|
|
let cwd = cwd.clone();
|
|
let home = home.path();
|
|
let sandbox = sandbox.clone();
|
|
let command = command.clone();
|
|
let protected_path = protected_file.path();
|
|
async move {
|
|
let started = backend
|
|
.start(ExecParams {
|
|
metadata: Default::default(),
|
|
process_id: format!("parallel-{index}").into(),
|
|
argv: vec![format!("/bin/{shell}"), "-lc".to_string(), command],
|
|
cwd,
|
|
env: HashMap::new(),
|
|
env_policy: Some(ExecEnvPolicy {
|
|
inherit: ShellEnvironmentPolicyInherit::None,
|
|
ignore_default_excludes: false,
|
|
exclude: Vec::new(),
|
|
r#set: HashMap::from([
|
|
("HOME".to_string(), home.to_string_lossy().into_owned()),
|
|
("PATH".to_string(), "/usr/bin:/bin".to_string()),
|
|
(
|
|
"SNAPSHOT_TRANSPORT_PROBE".to_string(),
|
|
protected_path.to_string_lossy().into_owned(),
|
|
),
|
|
]),
|
|
include_only: Vec::new(),
|
|
}),
|
|
shell_snapshot: Some(ShellSnapshotRequest {
|
|
scope_id: "parallel".to_string(),
|
|
shell: ShellInfo {
|
|
name: shell.to_string(),
|
|
path: format!("/bin/{shell}"),
|
|
},
|
|
}),
|
|
tty,
|
|
pipe_stdin: true,
|
|
arg0: None,
|
|
sandbox,
|
|
enforce_managed_network: false,
|
|
managed_network: None,
|
|
network_proxy: None,
|
|
})
|
|
.await?;
|
|
started
|
|
.process
|
|
.write(format!("input-{index}\n").into_bytes())
|
|
.await?;
|
|
let (output, errors, status, closed) =
|
|
collect_process_output_from_events(started.process).await?;
|
|
assert!(
|
|
output.ends_with(&format!("restored:input-{index}")),
|
|
"{output:?}"
|
|
);
|
|
assert_eq!((errors, status, closed), (String::new(), Some(7), true));
|
|
Ok::<_, anyhow::Error>(())
|
|
}
|
|
});
|
|
for result in futures::future::join_all(commands).await {
|
|
result?;
|
|
}
|
|
assert_eq!(std::fs::read_to_string(home.path().join("captures"))?, "x");
|
|
assert_eq!(std::fs::read_to_string(protected_file.path())?, "protected");
|
|
Ok(())
|
|
}
|