From e351f974f6cf0dc895270fb76f63a1e3e16d167f Mon Sep 17 00:00:00 2001 From: Vaibhav Srivastav Date: Wed, 24 Jun 2026 15:18:23 +0100 Subject: [PATCH] Cache remote Bash environment snapshots --- codex-rs/Cargo.lock | 1 + .../core/src/unified_exec/process_manager.rs | 1 + codex-rs/exec-server-protocol/src/protocol.rs | 29 +- codex-rs/exec-server/Cargo.toml | 3 +- codex-rs/exec-server/src/bash_env_snapshot.rs | 30 ++ .../exec-server/src/bash_env_snapshot_unix.rs | 495 ++++++++++++++++++ codex-rs/exec-server/src/environment.rs | 2 + codex-rs/exec-server/src/lib.rs | 2 + codex-rs/exec-server/src/local_process.rs | 168 +++++- .../exec-server/src/process_sandbox_tests.rs | 3 + .../exec-server/src/server/handler/tests.rs | 1 + .../exec-server/src/server/process_handler.rs | 4 + codex-rs/exec-server/src/server/processor.rs | 1 + .../src/server/session_registry.rs | 1 + codex-rs/exec-server/tests/exec_process.rs | 57 ++ codex-rs/exec-server/tests/relay.rs | 1 + .../rmcp-client/src/stdio_server_launcher.rs | 1 + 17 files changed, 779 insertions(+), 21 deletions(-) create mode 100644 codex-rs/exec-server/src/bash_env_snapshot.rs create mode 100644 codex-rs/exec-server/src/bash_env_snapshot_unix.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 3324fbd327..5151cfa5b2 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2905,6 +2905,7 @@ dependencies = [ "serde", "serde_json", "serial_test", + "sha2 0.10.9", "tempfile", "test-case", "thiserror 2.0.18", diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 5e89445765..34caa74c4c 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -183,6 +183,7 @@ fn exec_server_params_for_request( sandbox: request.exec_server_sandbox.clone(), enforce_managed_network: request.exec_server_enforce_managed_network, managed_network: request.exec_server_managed_network.clone(), + bash_env_snapshot: None, } } diff --git a/codex-rs/exec-server-protocol/src/protocol.rs b/codex-rs/exec-server-protocol/src/protocol.rs index 133654dc0a..5f5e3ac443 100644 --- a/codex-rs/exec-server-protocol/src/protocol.rs +++ b/codex-rs/exec-server-protocol/src/protocol.rs @@ -144,6 +144,21 @@ pub struct ExecParams { /// continue to fail closed. This preserves compatibility with older clients. #[serde(default)] pub managed_network: Option, + /// Optional request to reuse a workspace-scoped Bash environment snapshot on the executor. + /// Older exec-server peers ignore this field, so callers must treat reuse as opportunistic. + #[serde(default)] + pub bash_env_snapshot: Option, +} + +/// Parameters for executor-owned Bash environment snapshot capture and replay. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BashEnvSnapshotParams { + /// Workspace root that owns the captured environment. The command cwd must remain inside it. + /// Direct `BASH_ENV` startup must not be command-dependent, trap-setting, or builtin-shadowing. + pub workspace_root: PathUri, + /// Process environment keys that must win after the snapshot is sourced. + pub preserve_env_keys: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -549,6 +564,7 @@ mod base64_bytes { #[cfg(test)] mod tests { + use super::BashEnvSnapshotParams; use super::EnvironmentInfo; use super::ExecParams; use super::FsReadFileParams; @@ -563,14 +579,14 @@ mod tests { use std::collections::HashMap; #[test] - fn exec_params_managed_network_context_round_trips_and_defaults_for_legacy_peers() { + fn exec_params_optional_context_round_trips_and_defaults_for_legacy_peers() { let cwd = PathUri::from_host_native_path(std::env::current_dir().expect("current directory")) .expect("cwd URI"); let params = ExecParams { process_id: ProcessId::from("managed-network"), argv: vec!["true".to_string()], - cwd, + cwd: cwd.clone(), env_policy: None, env: HashMap::new(), tty: false, @@ -582,6 +598,10 @@ mod tests { loopback_ports: vec![43123, 48081], allow_local_binding: false, }), + bash_env_snapshot: Some(BashEnvSnapshotParams { + workspace_root: cwd, + preserve_env_keys: vec!["PATH".to_string()], + }), }; let mut serialized = serde_json::to_value(¶ms).expect("serialize exec params"); @@ -600,10 +620,15 @@ mod tests { .as_object_mut() .expect("exec params object") .remove("managedNetwork"); + serialized + .as_object_mut() + .expect("exec params object") + .remove("bashEnvSnapshot"); let legacy: ExecParams = serde_json::from_value(serialized).expect("deserialize legacy exec params"); assert!(legacy.enforce_managed_network); assert_eq!(legacy.managed_network, None); + assert_eq!(legacy.bash_env_snapshot, None); } #[test] diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index 9f5383aa06..ae95012d37 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -33,6 +33,8 @@ reqwest = { workspace = true, features = ["json", "rustls-tls", "stream"] } prost = "0.14.3" serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +sha2 = { workspace = true } +tempfile = { workspace = true } thiserror = { workspace = true } toml = { workspace = true } tokio = { workspace = true, features = [ @@ -67,6 +69,5 @@ ctor = { workspace = true } http = { workspace = true } pretty_assertions = { workspace = true } serial_test = { workspace = true } -tempfile = { workspace = true } test-case = "3.3.1" wiremock = { workspace = true } diff --git a/codex-rs/exec-server/src/bash_env_snapshot.rs b/codex-rs/exec-server/src/bash_env_snapshot.rs new file mode 100644 index 0000000000..797544c791 --- /dev/null +++ b/codex-rs/exec-server/src/bash_env_snapshot.rs @@ -0,0 +1,30 @@ +#[cfg(not(unix))] +use std::collections::HashMap; + +use crate::ExecServerRuntimePaths; +use crate::protocol::ExecParams; + +#[cfg(unix)] +#[path = "bash_env_snapshot_unix.rs"] +mod imp; + +#[cfg(unix)] +pub(crate) use imp::BashEnvSnapshotCache; + +#[cfg(not(unix))] +#[derive(Default)] +pub(crate) struct BashEnvSnapshotCache(()); + +#[cfg(not(unix))] +impl BashEnvSnapshotCache { + pub(crate) async fn prepare_launch( + &self, + params: &ExecParams, + environment: &HashMap, + _runtime_paths: Option<&ExecServerRuntimePaths>, + ) -> (Vec, HashMap) { + (params.argv.clone(), environment.clone()) + } + + pub(crate) fn clear(&self) {} +} diff --git a/codex-rs/exec-server/src/bash_env_snapshot_unix.rs b/codex-rs/exec-server/src/bash_env_snapshot_unix.rs new file mode 100644 index 0000000000..16f74f31a9 --- /dev/null +++ b/codex-rs/exec-server/src/bash_env_snapshot_unix.rs @@ -0,0 +1,495 @@ +use std::collections::HashMap; +use std::fs::Metadata; +use std::io::Write; +use std::os::unix::fs::MetadataExt; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex; +use std::time::Duration; + +use sha2::Digest; +use sha2::Sha256; +use tempfile::TempDir; +use tokio::sync::OnceCell; +use tokio::time::timeout; +use uuid::Uuid; + +use super::ExecParams; +use super::ExecServerRuntimePaths; +use crate::process_sandbox::PreparedExecRequest; +use crate::process_sandbox::prepare_exec_request; + +const CAPTURE_TIMEOUT: Duration = Duration::from_secs(10); +const MAX_CACHE_ENTRIES: usize = 16; +const MAX_ENV_BYTES: usize = 256 * 1024; +const MAX_SCOPE_BYTES: usize = 384 * 1024; +const MAX_PRESERVE_KEYS: usize = 128; +const MAX_SNAPSHOT_BYTES: usize = 1024 * 1024; +type SnapshotCell = OnceCell>>; + +pub(crate) struct BashEnvSnapshotCache { + entries: Mutex>>, + root: Option, +} + +struct BashEnvSnapshotRequest { + key: [u8; 32], + environment: HashMap, + preserve_env_keys: Vec, + bash_env: String, +} + +struct BashEnvSnapshot { + path: PathBuf, + bash_env: String, + checksum: String, + prefix: String, +} + +impl Default for BashEnvSnapshotCache { + fn default() -> Self { + Self { + entries: Mutex::new(HashMap::new()), + root: tempfile::Builder::new() + .prefix("codex-exec-server-bash-env-") + .permissions(std::fs::Permissions::from_mode(0o700)) + .tempdir() + .ok(), + } + } +} + +impl BashEnvSnapshotCache { + pub(crate) async fn prepare_launch( + &self, + params: &ExecParams, + environment: &HashMap, + runtime_paths: Option<&ExecServerRuntimePaths>, + ) -> (Vec, HashMap) { + let Some(request) = BashEnvSnapshotRequest::new(params, environment) else { + return fallback(params, environment); + }; + let preserve_env_keys = request.preserve_env_keys.clone(); + let Some(snapshot) = self.snapshot(params, request, runtime_paths).await else { + return fallback(params, environment); + }; + let Some(argv) = wrap_command(¶ms.argv, &snapshot, &preserve_env_keys) else { + return fallback(params, environment); + }; + let mut environment = environment.clone(); + environment.remove("BASH_ENV"); + (argv, environment) + } + + pub(crate) fn clear(&self) { + self.entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clear(); + } + + async fn snapshot( + &self, + params: &ExecParams, + request: BashEnvSnapshotRequest, + runtime_paths: Option<&ExecServerRuntimePaths>, + ) -> Option> { + let key = request.key; + let cell = { + let mut entries = self + .entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if entries.len() >= MAX_CACHE_ENTRIES && !entries.contains_key(&key) { + return None; + } + Arc::clone( + entries + .entry(key) + .or_insert_with(|| Arc::new(OnceCell::new())), + ) + }; + cell.get_or_init(|| async { + self.capture(params, request, runtime_paths) + .await + .map(Arc::new) + }) + .await + .clone() + } + + async fn capture( + &self, + params: &ExecParams, + request: BashEnvSnapshotRequest, + runtime_paths: Option<&ExecServerRuntimePaths>, + ) -> Option { + let nonce = Uuid::new_v4().simple().to_string(); + let prefix = format!("__CODEX_SNAPSHOT_{}", nonce.to_ascii_uppercase()); + let marker = format!("# Codex remote Bash environment snapshot {nonce}"); + let stderr_marker = format!("# Codex remote Bash stderr complete {nonce}"); + let wrapper_end = format!("# Codex remote Bash capture complete {nonce}"); + let root = self.root.as_ref()?; + let mut wrapper = tempfile::NamedTempFile::new_in(root.path()).ok()?; + let script = capture_script( + &prefix, + &marker, + &stderr_marker, + &wrapper_end, + &request.bash_env, + &request.preserve_env_keys, + ); + wrapper.write_all(script.as_bytes()).ok()?; + wrapper.as_file().sync_all().ok()?; + let mut capture = params.clone(); + capture.bash_env_snapshot = None; + let mut environment = request.environment; + environment.insert("BASH_ENV".into(), wrapper.path().to_string_lossy().into()); + let prepared = prepare_exec_request(&capture, environment, runtime_paths).ok()?; + let (output, stderr) = capture_output(prepared).await?; + let marker_offset = output + .windows(marker.len()) + .position(|part| part == marker.as_bytes())?; + let stdout = &output[..marker_offset]; + let snapshot = + output[marker_offset..].strip_suffix(format!("{wrapper_end}\n").as_bytes())?; + let stderr = &stderr[..stderr + .windows(stderr_marker.len()) + .position(|part| part == stderr_marker.as_bytes())?]; + let mut contents = snapshot.to_vec(); + contents.extend(replay_output(stdout, /*fd*/ 3)); + contents.extend(replay_output(stderr, /*fd*/ 4)); + contents.extend_from_slice(b"builtin true\n"); + if !contents.ends_with(b"\n") { + contents.push(b'\n'); + } + if contents.len() > MAX_SNAPSHOT_BYTES { + return None; + } + let mut temp = tempfile::NamedTempFile::new_in(root.path()).ok()?; + temp.write_all(&contents).ok()?; + temp.as_file().sync_all().ok()?; + let path = root.path().join(format!("{nonce}.sh")); + temp.persist(&path).ok()?; + Some(BashEnvSnapshot { + path, + bash_env: request.bash_env, + checksum: format!("{:x}", Sha256::digest(&contents)), + prefix, + }) + } +} + +impl BashEnvSnapshotRequest { + fn new(params: &ExecParams, environment: &HashMap) -> Option { + let snapshot = params.bash_env_snapshot.as_ref()?; + if params.tty + || params.pipe_stdin + || params.arg0.is_some() + || params.argv.len() < 3 + || params.argv.get(1).map(String::as_str) != Some("-c") + || !params.cwd.starts_with(&snapshot.workspace_root) + || environment.iter().any(|(key, value)| { + (key == "builtin" || key.starts_with("BASH_FUNC_builtin")) + && value.trim_start().starts_with("()") + }) + { + return None; + } + let shell = Path::new(params.argv.first()?); + let bash_env = Path::new(environment.get("BASH_ENV")?); + if !shell.is_absolute() + || shell.file_name()?.to_str()? != "bash" + || !shell.is_file() + || !bash_env.is_absolute() + || !bash_env.is_file() + { + return None; + } + let bash_env_source = std::fs::read(bash_env).ok()?; + if bash_env_source.len() > MAX_SNAPSHOT_BYTES || unsupported_startup(&bash_env_source) { + return None; + } + let mut preserve_env_keys = snapshot.preserve_env_keys.clone(); + if preserve_env_keys.len() > MAX_PRESERVE_KEYS + || preserve_env_keys + .iter() + .any(|key| key.len() > 128 || !valid_variable(key)) + { + return None; + } + preserve_env_keys.retain(|key| key != "BASH_ENV"); + preserve_env_keys.sort_unstable(); + preserve_env_keys.dedup(); + + let mut environment_scope = environment + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect::>(); + environment_scope.sort_unstable(); + let environment_bytes = environment_scope + .iter() + .try_fold(0usize, |size, (key, value)| { + size.checked_add(key.len())?.checked_add(value.len()) + })?; + if environment_bytes > MAX_ENV_BYTES { + return None; + } + let mut scope = serde_json::to_value(( + unsafe { libc::geteuid() }, + &snapshot.workspace_root, + ¶ms.cwd, + shell.to_string_lossy(), + file_identity(&shell.metadata().ok()?), + bash_env.to_string_lossy(), + file_identity(&bash_env.metadata().ok()?), + environment_scope, + ¶ms.env_policy, + ¶ms.sandbox, + params.enforce_managed_network, + ¶ms.managed_network, + &preserve_env_keys, + )) + .ok()?; + sort_json(&mut scope); + let scope = serde_json::to_vec(&scope).ok()?; + if scope.len() > MAX_SCOPE_BYTES { + return None; + } + let key = Sha256::digest(scope).into(); + Some(Self { + key, + environment: environment.clone(), + preserve_env_keys, + bash_env: bash_env.to_string_lossy().into_owned(), + }) + } +} + +impl Drop for BashEnvSnapshot { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +async fn capture_output(prepared: PreparedExecRequest) -> Option<(Vec, Vec)> { + timeout(CAPTURE_TIMEOUT, async move { + let (program, args) = prepared.command.split_first()?; + let spawned = codex_utils_pty::spawn_pipe_process_no_stdin( + program, + args, + prepared.cwd.as_path(), + &prepared.env, + &prepared.arg0, + ) + .await + .ok()?; + let codex_utils_pty::SpawnedProcess { + session: _session, + stdout_rx, + stderr_rx, + exit_rx, + } = spawned; + let (stdout, stderr, exit) = tokio::join!( + collect_output(stdout_rx), + collect_output(stderr_rx), + exit_rx + ); + (exit.ok()? == 0).then_some((stdout?, stderr?)) + }) + .await + .ok() + .flatten() +} + +async fn collect_output(mut receiver: tokio::sync::mpsc::Receiver>) -> Option> { + let mut output = Vec::new(); + while let Some(chunk) = receiver.recv().await { + if output.len().checked_add(chunk.len())? > MAX_SNAPSHOT_BYTES { + return None; + } + output.extend_from_slice(&chunk); + } + Some(output) +} + +fn capture_script( + prefix: &str, + marker: &str, + stderr_marker: &str, + wrapper_end: &str, + bash_env: &str, + preserve_env_keys: &[String], +) -> String { + let mut excluded = ":BASH:BASHOPTS:BASHPID:BASH_ALIASES:BASH_ARGC:BASH_ARGV:BASH_ARGV0:\ +BASH_CMDS:BASH_COMMAND:BASH_ENV:BASH_EXECUTION_STRING:BASH_LINENO:BASH_REMATCH:BASH_SOURCE:BASH_SUBSHELL:\ +BASH_VERSINFO:BASH_VERSION:COPROC:DIRSTACK:EPOCHREALTIME:EPOCHSECONDS:EUID:FUNCNAME:GROUPS:\ +HISTCMD:HOSTTYPE:LINENO:MACHTYPE:OLDPWD:OSTYPE:PIPESTATUS:PPID:PWD:RANDOM:SECONDS:SHELLOPTS:SHLVL:SRANDOM:UID:_:" + .to_string(); + for key in preserve_env_keys { + excluded.push_str(key); + excluded.push(':'); + } + r#"if ! builtin type -P sha256sum >/dev/null 2>&1 && ! builtin type -P shasum >/dev/null 2>&1; then builtin exit 125; fi +builtin unset __CODEX_PREFIX___EXCLUDED __CODEX_PREFIX___P_NAMES __CODEX_PREFIX___NAME __CODEX_PREFIX___DECL __CODEX_PREFIX___ALIASES __CODEX_PREFIX___OPTS __CODEX_PREFIX___SHOPTS __CODEX_PREFIX___FUNC_ATTRS __CODEX_PREFIX___CWD __CODEX_PREFIX___UMASK +builtin export BASH_ENV='__CODEX_BASH_ENV__' +__CODEX_PREFIX___EXCLUDED='__CODEX_EXCLUDED__'; __CODEX_PREFIX___P_NAMES=; __CODEX_PREFIX___NAME=; __CODEX_PREFIX___DECL= +__CODEX_PREFIX___ALIASES=; __CODEX_PREFIX___OPTS=; __CODEX_PREFIX___SHOPTS=; __CODEX_PREFIX___FUNC_ATTRS=; __CODEX_PREFIX___CWD=; __CODEX_PREFIX___UMASK= +while IFS= builtin read -r __CODEX_PREFIX___NAME; do + [[ "$__CODEX_PREFIX___NAME" != __CODEX_PREFIX___* && "$__CODEX_PREFIX___EXCLUDED" != *":$__CODEX_PREFIX___NAME:"* && "$__CODEX_PREFIX___NAME" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue + __CODEX_PREFIX___P_NAMES="${__CODEX_PREFIX___P_NAMES}${__CODEX_PREFIX___NAME}"$'\n' +done < <(builtin compgen -A variable) +builtin source "$BASH_ENV" +if [[ $- == *x* || $- == *v* ]]; then builtin exit 125; fi +builtin printf '%s\n' '__CODEX_MARKER__'; builtin printf '%s\n' '__CODEX_STDERR_MARKER__' >&2 +__CODEX_PREFIX___ALIASES=$(builtin alias -p 2>/dev/null || builtin true) +__CODEX_PREFIX___OPTS=$(builtin set +o); if [[ $- == *e* ]]; then __CODEX_PREFIX___OPTS=${__CODEX_PREFIX___OPTS/set +o errexit/set -o errexit}; fi +__CODEX_PREFIX___SHOPTS=$(builtin shopt -p 2>/dev/null || builtin true) +builtin shopt -u extdebug 2>/dev/null || builtin true; __CODEX_PREFIX___FUNC_ATTRS=$(builtin declare -F) +__CODEX_PREFIX___CWD=$(builtin pwd -L); __CODEX_PREFIX___UMASK=$(builtin umask) +builtin set +e; builtin set +u; builtin unalias -a 2>/dev/null || builtin true +__CODEX_PREFIX___ALIASES=${__CODEX_PREFIX___ALIASES//$'\n'/$'\nbuiltin '}; __CODEX_PREFIX___OPTS=${__CODEX_PREFIX___OPTS//$'\n'/$'\nbuiltin '}; __CODEX_PREFIX___SHOPTS=${__CODEX_PREFIX___SHOPTS//$'\n'/$'\nbuiltin '} +[[ -z "$__CODEX_PREFIX___ALIASES" ]] || __CODEX_PREFIX___ALIASES="builtin $__CODEX_PREFIX___ALIASES" +[[ -z "$__CODEX_PREFIX___OPTS" ]] || __CODEX_PREFIX___OPTS="builtin $__CODEX_PREFIX___OPTS" +[[ -z "$__CODEX_PREFIX___SHOPTS" ]] || __CODEX_PREFIX___SHOPTS="builtin $__CODEX_PREFIX___SHOPTS" +builtin printf 'builtin cd -L -- %q || builtin return 1\n' "$__CODEX_PREFIX___CWD" +builtin declare -f +while IFS= builtin read -r __CODEX_PREFIX___DECL; do + [[ "$__CODEX_PREFIX___DECL" == "declare -f "* ]] && continue + builtin printf 'builtin %s\n' "$__CODEX_PREFIX___DECL" +done <<< "$__CODEX_PREFIX___FUNC_ATTRS" +builtin printf '\n' +while IFS= builtin read -r __CODEX_PREFIX___NAME; do + [[ "$__CODEX_PREFIX___NAME" != __CODEX_PREFIX___* && "$__CODEX_PREFIX___EXCLUDED" != *":$__CODEX_PREFIX___NAME:"* && "$__CODEX_PREFIX___NAME" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue + __CODEX_PREFIX___DECL=$(builtin declare -p "$__CODEX_PREFIX___NAME" 2>/dev/null) || continue; builtin printf 'builtin unset -v %s\n' "$__CODEX_PREFIX___NAME"; __CODEX_PREFIX___NAME=${__CODEX_PREFIX___DECL#declare }; __CODEX_PREFIX___NAME=${__CODEX_PREFIX___NAME%% *} + if [[ "$__CODEX_PREFIX___NAME" == -*[aA]* ]]; then __CODEX_PREFIX___DECL=${__CODEX_PREFIX___DECL#declare }; __CODEX_PREFIX___DECL=${__CODEX_PREFIX___DECL#* }; if [[ "$__CODEX_PREFIX___NAME" == *A* ]]; then __CODEX_PREFIX___CWD=A; else __CODEX_PREFIX___CWD=a; fi + if [[ "${__CODEX_PREFIX___DECL#*=}" == \(* ]]; then builtin printf 'builtin declare -%s %q\n' "$__CODEX_PREFIX___CWD" "$__CODEX_PREFIX___DECL"; else builtin printf 'builtin declare -%s %s\n' "$__CODEX_PREFIX___CWD" "$__CODEX_PREFIX___DECL"; fi; builtin printf 'builtin declare %s %s\n' "$__CODEX_PREFIX___NAME" "${__CODEX_PREFIX___DECL%%=*}"; else builtin printf 'builtin %s\n' "$__CODEX_PREFIX___DECL"; fi +done < <(builtin compgen -A variable) +while IFS= builtin read -r __CODEX_PREFIX___NAME; do + [[ -z "$__CODEX_PREFIX___NAME" ]] && continue + builtin declare -p "$__CODEX_PREFIX___NAME" >/dev/null 2>&1 || builtin printf 'builtin unset -v %s\n' "$__CODEX_PREFIX___NAME" +done < <(builtin printf '%s' "$__CODEX_PREFIX___P_NAMES") +builtin printf '\n' +if __CODEX_PREFIX___DECL=$(builtin declare -p BASH_ENV 2>/dev/null); then builtin printf 'builtin %s\n' "$__CODEX_PREFIX___DECL"; else builtin printf 'builtin unset -v BASH_ENV\n'; fi +builtin printf '\nbuiltin umask %s\n' "$__CODEX_PREFIX___UMASK" +builtin printf '%s\n' "$__CODEX_PREFIX___OPTS" +builtin printf '\n%s\n\n' "$__CODEX_PREFIX___SHOPTS" +[[ -z "$__CODEX_PREFIX___ALIASES" ]] || builtin printf '%s\n' "$__CODEX_PREFIX___ALIASES" +builtin printf '%s\n' '__CODEX_WRAPPER_END__'; builtin exit 0 +"# + .replace("__CODEX_PREFIX__", prefix) + .replace("__CODEX_MARKER__", marker) + .replace("__CODEX_STDERR_MARKER__", stderr_marker) + .replace("__CODEX_WRAPPER_END__", wrapper_end) + .replace("__CODEX_BASH_ENV__", "e(bash_env)) + .replace("__CODEX_EXCLUDED__", &excluded) +} + +fn replay_output(output: &[u8], fd: u8) -> Vec { + if output.is_empty() { + return Vec::new(); + } + let escaped = output + .iter() + .map(|byte| format!("\\{byte:03o}")) + .collect::(); + format!("builtin printf '%b' '{escaped}' >&{fd}\n").into_bytes() +} + +fn wrap_command( + argv: &[String], + snapshot: &BashEnvSnapshot, + preserve_env_keys: &[String], +) -> Option> { + let mut script = String::new(); + for (index, key) in preserve_env_keys.iter().enumerate() { + script.push_str(&format!( + "{prefix}_{index}_SET=\"${{{key}+x}}\"\n{prefix}_{index}_VALUE=\"${{{key}-}}\"\n", + prefix = snapshot.prefix, + )); + } + script.push_str("builtin unset BASH_ENV\n"); + script.push_str(&format!( + "{prefix}_HASH=$(if builtin command -v sha256sum >/dev/null 2>&1; then builtin command sha256sum '{path}'; elif builtin command -v shasum >/dev/null 2>&1; then builtin command shasum -a 256 '{path}'; fi)\n\ + {prefix}_HASH=${{{prefix}_HASH%% *}}\n\ + if [[ \"${{{prefix}_HASH}}\" = '{checksum}' ]] && builtin source '{path}' 3>&1 4>&2 >/dev/null 2>&1; then builtin true; else builtin export BASH_ENV='{bash_env}'; builtin source \"$BASH_ENV\" || builtin true; fi\n\ + builtin unset {prefix}_HASH\n", + prefix = snapshot.prefix, + path = quote(&snapshot.path.to_string_lossy()), + checksum = snapshot.checksum, + bash_env = quote(&snapshot.bash_env), + )); + for (index, key) in preserve_env_keys.iter().enumerate() { + script.push_str(&format!( + "if [[ \"${{{prefix}_{index}_SET}}\" = x ]]; then builtin export {key}=\"${{{prefix}_{index}_VALUE}}\"; else builtin unset {key}; fi\nbuiltin unset {prefix}_{index}_SET {prefix}_{index}_VALUE\n", + prefix = snapshot.prefix, + )); + } + script.push_str(&format!( + "BASH_EXECUTION_STRING='{}'\n", + quote(argv.get(2)?) + )); + script.push_str(argv.get(2)?); + let mut wrapped = vec![argv.first()?.clone(), "-c".to_string(), script]; + wrapped.extend(argv.get(3..)?.iter().cloned()); + Some(wrapped) +} + +fn file_identity(metadata: &Metadata) -> (u64, u64, u64, i64, i64, i64, i64, u32) { + ( + metadata.dev(), + metadata.ino(), + metadata.len(), + metadata.mtime(), + metadata.mtime_nsec(), + metadata.ctime(), + metadata.ctime_nsec(), + metadata.mode(), + ) +} + +fn sort_json(value: &mut serde_json::Value) { + match value { + serde_json::Value::Array(values) => values.iter_mut().for_each(sort_json), + serde_json::Value::Object(map) => { + map.values_mut().for_each(sort_json); + map.sort_keys(); + } + _ => {} + } +} + +fn valid_variable(name: &str) -> bool { + let mut chars = name.chars(); + matches!(chars.next(), Some('_') | Some('a'..='z') | Some('A'..='Z')) + && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) +} + +fn unsupported_startup(source: &[u8]) -> bool { + let positional = |byte| matches!(byte, b'0'..=b'9' | b'@' | b'*' | b'#'); + source + .windows(b"BASH_EXECUTION_STRING".len()) + .any(|part| part == b"BASH_EXECUTION_STRING") + || source + .windows(2) + .any(|part| part[0] == b'$' && positional(part[1])) + || source + .windows(3) + .any(|part| part.starts_with(b"${") && positional(part[2])) + || source + .split(|byte| !byte.is_ascii_alphanumeric() && *byte != b'_') + .any(|word| word == b"trap" || word == b"builtin") +} + +fn quote(value: &str) -> String { + value.replace('\'', "'\"'\"'") +} + +fn fallback( + params: &ExecParams, + environment: &HashMap, +) -> (Vec, HashMap) { + (params.argv.clone(), environment.clone()) +} diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 664ef730d0..c69633d93e 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -1155,6 +1155,7 @@ mod tests { pipe_stdin: false, arg0: None, sandbox: None, + bash_env_snapshot: None, enforce_managed_network: false, managed_network: None, }) @@ -1195,6 +1196,7 @@ mod tests { pipe_stdin: false, arg0: None, sandbox: Some(sandbox), + bash_env_snapshot: None, enforce_managed_network: false, managed_network: None, }) diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index 69d4c07dfb..7cf6dfa657 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -1,3 +1,4 @@ +mod bash_env_snapshot; mod client; mod client_api; mod client_transport; @@ -88,6 +89,7 @@ pub use process::ExecProcessEvent; pub use process::ExecProcessEventReceiver; pub use process::ExecProcessFuture; pub use process::StartedExecProcess; +pub use protocol::BashEnvSnapshotParams; pub use protocol::ByteChunk; pub use protocol::EnvironmentInfo; pub use protocol::ExecClosedNotification; diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index 973019808a..a252fb6875 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -3,6 +3,7 @@ use std::collections::HashSet; use std::collections::VecDeque; use std::collections::hash_map::Entry; use std::sync::Arc; +use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering; use std::time::Duration; @@ -33,6 +34,7 @@ use crate::ExecServerError; use crate::ExecServerRuntimePaths; use crate::ProcessId; use crate::StartedExecProcess; +use crate::bash_env_snapshot::BashEnvSnapshotCache; use crate::process::ExecProcessEventLog; use crate::process_sandbox::prepare_exec_request; use crate::protocol::EXEC_CLOSED_METHOD; @@ -126,7 +128,10 @@ impl AcceptedStdinWriteIds { } } -struct ProcessStart; +#[derive(Default)] +struct ProcessStart { + cancelled: AtomicBool, +} enum ProcessEntry { Starting(Arc), @@ -136,6 +141,27 @@ enum ProcessEntry { struct Inner { notifications: std::sync::RwLock>, processes: Mutex>, + bash_env_snapshots: BashEnvSnapshotCache, +} + +struct ProcessStartGuard(Option<(Arc, ProcessId, Arc)>); + +impl Drop for ProcessStartGuard { + fn drop(&mut self) { + let Some((inner, process_id, start)) = self.0.take() else { + return; + }; + start.cancelled.store(true, Ordering::Release); + tokio::spawn(async move { + let mut process_map = inner.processes.lock().await; + if matches!( + process_map.get(&process_id), + Some(ProcessEntry::Starting(current)) if Arc::ptr_eq(current, &start) + ) { + process_map.remove(&process_id); + } + }); + } } #[derive(Clone)] @@ -184,12 +210,14 @@ impl LocalProcess { inner: Arc::new(Inner { notifications: std::sync::RwLock::new(Some(notifications)), processes: Mutex::new(HashMap::new()), + bash_env_snapshots: BashEnvSnapshotCache::default(), }), runtime_paths, } } pub(crate) async fn shutdown(&self) { + self.clear_bash_env_snapshots(); let remaining = { let mut processes = self.inner.processes.lock().await; processes @@ -214,22 +242,27 @@ impl LocalProcess { *notification_sender = notifications; } + pub(crate) fn clear_bash_env_snapshots(&self) { + self.inner.bash_env_snapshots.clear(); + } + async fn start_process( &self, - params: ExecParams, + mut params: ExecParams, ) -> Result<(ExecResponse, watch::Sender, ExecProcessEventLog), JSONRPCErrorError> { + if params.argv.is_empty() { + return Err(invalid_params("argv must not be empty".to_string())); + } let process_id = params.process_id.clone(); - let prepared = - prepare_exec_request(¶ms, child_env(¶ms), self.runtime_paths.as_ref())?; - let (program, args) = prepared - .command - .split_first() - .ok_or_else(|| invalid_params("argv must not be empty".to_string()))?; - - let start = Arc::new(ProcessStart); + let start = Arc::new(ProcessStart::default()); { let mut process_map = self.inner.processes.lock().await; - if process_map.contains_key(&process_id) { + let reusable = match process_map.get(&process_id) { + None => true, + Some(ProcessEntry::Starting(current)) => current.cancelled.load(Ordering::Acquire), + Some(ProcessEntry::Running(_)) => false, + }; + if !reusable { return Err(invalid_request(format!( "process {process_id} already exists" ))); @@ -239,6 +272,33 @@ impl LocalProcess { ProcessEntry::Starting(Arc::clone(&start)), ); } + let mut start_guard = ProcessStartGuard(Some(( + Arc::clone(&self.inner), + process_id.clone(), + Arc::clone(&start), + ))); + + let environment = child_env(¶ms); + let (argv, environment) = self + .inner + .bash_env_snapshots + .prepare_launch(¶ms, &environment, self.runtime_paths.as_ref()) + .await; + params.argv = argv; + let prepared = match prepare_exec_request(¶ms, environment, self.runtime_paths.as_ref()) + { + Ok(prepared) => prepared, + Err(err) => { + self.remove_starting_process(&process_id, &start).await; + start_guard.0 = None; + return Err(err); + } + }; + let Some((program, args)) = prepared.command.split_first() else { + self.remove_starting_process(&process_id, &start).await; + start_guard.0 = None; + return Err(invalid_params("argv must not be empty".to_string())); + }; let spawned_result = if params.tty { codex_utils_pty::spawn_pty_process( @@ -272,13 +332,8 @@ impl LocalProcess { let spawned = match spawned_result { Ok(spawned) => spawned, Err(err) => { - let mut process_map = self.inner.processes.lock().await; - if matches!( - process_map.get(&process_id), - Some(ProcessEntry::Starting(current)) if Arc::ptr_eq(current, &start) - ) { - process_map.remove(&process_id); - } + self.remove_starting_process(&process_id, &start).await; + start_guard.0 = None; return Err(internal_error(err.to_string())); } }; @@ -324,6 +379,7 @@ impl LocalProcess { })), ); } + start_guard.0 = None; tokio::spawn(stream_output( process_id.clone(), @@ -357,6 +413,16 @@ impl LocalProcess { Ok((ExecResponse { process_id }, wake_tx, events)) } + async fn remove_starting_process(&self, process_id: &ProcessId, start: &Arc) { + let mut process_map = self.inner.processes.lock().await; + if matches!( + process_map.get(process_id), + Some(ProcessEntry::Starting(current)) if Arc::ptr_eq(current, start) + ) { + process_map.remove(process_id); + } + } + pub(crate) async fn exec(&self, params: ExecParams) -> Result { self.start_process(params) .await @@ -964,6 +1030,7 @@ mod tests { pipe_stdin: false, arg0: None, sandbox: None, + bash_env_snapshot: None, enforce_managed_network: false, managed_network: None, } @@ -993,6 +1060,71 @@ mod tests { assert_eq!(error, expected); } + #[cfg(unix)] + #[tokio::test] + async fn cancelled_snapshot_capture_allows_immediate_retry_and_kills_descendants() { + let temp = tempfile::tempdir().expect("tempdir"); + let bash_env = temp.path().join("bash_env.sh"); + let child_pid_file = temp.path().join("child.pid"); + std::fs::write( + &bash_env, + format!( + "sleep 10 & printf '%s' \"$!\" > '{}'; wait\n", + child_pid_file.display() + ), + ) + .expect("write slow BASH_ENV"); + let cwd = PathUri::from_host_native_path(temp.path()).expect("cwd URI"); + let mut params = test_exec_params(HashMap::from([ + ("BASH_ENV".to_string(), bash_env.display().to_string()), + ("PATH".to_string(), std::env::var("PATH").expect("PATH")), + ])); + params.argv = vec![ + "/bin/bash".to_string(), + "-c".to_string(), + "true".to_string(), + ]; + params.cwd = cwd.clone(); + params.bash_env_snapshot = Some(crate::protocol::BashEnvSnapshotParams { + workspace_root: cwd, + preserve_env_keys: Vec::new(), + }); + let backend = LocalProcess::default(); + let task = tokio::spawn({ + let (backend, params) = (backend.clone(), params.clone()); + async move { backend.start_process(params).await } + }); + let child_pid = timeout(Duration::from_secs(1), async { + loop { + if let Ok(contents) = std::fs::read_to_string(&child_pid_file) + && let Ok(pid) = contents.parse::() + { + break pid; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("capture should start a descendant"); + task.abort(); + let _ = task.await; + std::fs::write(&bash_env, ":\n").expect("replace slow BASH_ENV"); + timeout(Duration::from_secs(2), backend.start_process(params)) + .await + .expect("retry should not time out") + .expect("process id should be reusable"); + timeout(Duration::from_secs(2), async { + while unsafe { libc::kill(child_pid, 0) } == 0 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap_or_else(|_| { + unsafe { libc::kill(child_pid, libc::SIGKILL) }; + panic!("capture descendant {child_pid} should be terminated"); + }); + } + #[test] fn child_env_defaults_to_exact_env() { let params = test_exec_params(HashMap::from([("ONLY_THIS".to_string(), "1".to_string())])); diff --git a/codex-rs/exec-server/src/process_sandbox_tests.rs b/codex-rs/exec-server/src/process_sandbox_tests.rs index bc71b57320..22daaf598e 100644 --- a/codex-rs/exec-server/src/process_sandbox_tests.rs +++ b/codex-rs/exec-server/src/process_sandbox_tests.rs @@ -45,6 +45,7 @@ fn sandbox_request_wraps_native_argv_on_executor() { pipe_stdin: false, arg0: None, sandbox: Some(sandbox), + bash_env_snapshot: None, enforce_managed_network: false, managed_network: None, }; @@ -106,6 +107,7 @@ fn sandbox_request_allows_prepared_managed_proxy_port() { pipe_stdin: false, arg0: None, sandbox: Some(sandbox), + bash_env_snapshot: None, enforce_managed_network: true, managed_network: Some(ManagedNetworkSandboxContext { loopback_ports: vec![43123], @@ -142,6 +144,7 @@ fn native_request_preserves_native_launch_fields() { pipe_stdin: false, arg0: Some("custom-arg0".to_string()), sandbox: None, + bash_env_snapshot: None, enforce_managed_network: false, managed_network: None, }; diff --git a/codex-rs/exec-server/src/server/handler/tests.rs b/codex-rs/exec-server/src/server/handler/tests.rs index 2cea99b214..e17540faed 100644 --- a/codex-rs/exec-server/src/server/handler/tests.rs +++ b/codex-rs/exec-server/src/server/handler/tests.rs @@ -35,6 +35,7 @@ fn exec_params_with_argv(process_id: &str, argv: Vec) -> ExecParams { pipe_stdin: false, arg0: None, sandbox: None, + bash_env_snapshot: None, enforce_managed_network: false, managed_network: None, } diff --git a/codex-rs/exec-server/src/server/process_handler.rs b/codex-rs/exec-server/src/server/process_handler.rs index 9ff6690411..6f75af8e41 100644 --- a/codex-rs/exec-server/src/server/process_handler.rs +++ b/codex-rs/exec-server/src/server/process_handler.rs @@ -33,6 +33,10 @@ impl ProcessHandler { self.process.shutdown().await; } + pub(crate) fn clear_bash_env_snapshots(&self) { + self.process.clear_bash_env_snapshots(); + } + pub(crate) fn set_notification_sender(&self, notifications: Option) { self.process.set_notification_sender(notifications); } diff --git a/codex-rs/exec-server/src/server/processor.rs b/codex-rs/exec-server/src/server/processor.rs index e163592130..7ebde78093 100644 --- a/codex-rs/exec-server/src/server/processor.rs +++ b/codex-rs/exec-server/src/server/processor.rs @@ -439,6 +439,7 @@ mod tests { pipe_stdin: false, arg0: None, sandbox: None, + bash_env_snapshot: None, enforce_managed_network: false, managed_network: None, } diff --git a/codex-rs/exec-server/src/server/session_registry.rs b/codex-rs/exec-server/src/server/session_registry.rs index 4d2ef7f893..88a292e207 100644 --- a/codex-rs/exec-server/src/server/session_registry.rs +++ b/codex-rs/exec-server/src/server/session_registry.rs @@ -248,6 +248,7 @@ impl SessionHandle { if !self.entry.detach(self.connection_id) { return; } + self.entry.process.clear_bash_env_snapshots(); let registry = Arc::clone(&self.registry); let session_id = self.entry.session_id.clone(); diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index 8fe5fe0be9..7617bdd74f 100644 --- a/codex-rs/exec-server/tests/exec_process.rs +++ b/codex-rs/exec-server/tests/exec_process.rs @@ -5,6 +5,8 @@ use std::sync::Arc; use anyhow::Context; use anyhow::Result; +#[cfg(unix)] +use codex_exec_server::BashEnvSnapshotParams; use codex_exec_server::Environment; use codex_exec_server::ExecBackend; use codex_exec_server::ExecOutputStream; @@ -82,6 +84,7 @@ async fn assert_exec_process_starts_and_exits(use_remote: bool) -> Result<()> { pipe_stdin: false, arg0: None, sandbox: None, + bash_env_snapshot: None, enforce_managed_network: false, managed_network: None, }) @@ -226,6 +229,7 @@ async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> { pipe_stdin: false, arg0: None, sandbox: None, + bash_env_snapshot: None, enforce_managed_network: false, managed_network: None, }) @@ -260,6 +264,7 @@ async fn assert_exec_process_pushes_events(use_remote: bool) -> Result<()> { pipe_stdin: false, arg0: None, sandbox: None, + bash_env_snapshot: None, enforce_managed_network: false, managed_network: None, }) @@ -310,6 +315,7 @@ async fn assert_exec_process_replays_events_after_close(use_remote: bool) -> Res pipe_stdin: false, arg0: None, sandbox: None, + bash_env_snapshot: None, enforce_managed_network: false, managed_network: None, }) @@ -361,6 +367,7 @@ async fn assert_exec_process_retains_output_after_exit_until_streams_close( pipe_stdin: false, arg0: None, sandbox: None, + bash_env_snapshot: None, enforce_managed_network: false, managed_network: None, }) @@ -437,6 +444,7 @@ async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> { pipe_stdin: false, arg0: None, sandbox: None, + bash_env_snapshot: None, enforce_managed_network: false, managed_network: None, }) @@ -477,6 +485,7 @@ async fn assert_exec_process_write_then_read_without_tty(use_remote: bool) -> Re pipe_stdin: true, arg0: None, sandbox: None, + bash_env_snapshot: None, enforce_managed_network: false, managed_network: None, }) @@ -513,6 +522,7 @@ async fn assert_exec_process_rejects_write_without_pipe_stdin(use_remote: bool) pipe_stdin: false, arg0: None, sandbox: None, + bash_env_snapshot: None, enforce_managed_network: false, managed_network: None, }) @@ -550,6 +560,7 @@ async fn assert_exec_process_signal_interrupts_process(use_remote: bool) -> Resu pipe_stdin: false, arg0: None, sandbox: None, + bash_env_snapshot: None, enforce_managed_network: false, managed_network: None, }) @@ -606,6 +617,7 @@ async fn assert_exec_process_signal_reports_unsupported_on_windows(use_remote: b pipe_stdin: false, arg0: None, sandbox: None, + bash_env_snapshot: None, enforce_managed_network: false, managed_network: None, }) @@ -649,6 +661,7 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe( pipe_stdin: false, arg0: None, sandbox: None, + bash_env_snapshot: None, enforce_managed_network: false, managed_network: None, }) @@ -710,6 +723,7 @@ async fn remote_exec_process_recovers_after_transport_disconnect() -> Result<()> pipe_stdin: true, arg0: None, sandbox: None, + bash_env_snapshot: None, enforce_managed_network: false, managed_network: None, }) @@ -816,6 +830,49 @@ async fn remote_exec_process_recovers_after_transport_disconnect() -> Result<()> Ok(()) } +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial_test::serial(remote_exec_server)] +async fn remote_bash_env_snapshot_is_reused_across_execs() -> Result<()> { + let server = exec_server().await?; + let backend = + Environment::create_for_tests(Some(server.websocket_url().to_string()))?.get_exec_backend(); + let temp = tempfile::tempdir()?; + let bash_env = temp.path().join("bash_env.sh"); + std::fs::write(&bash_env, "printf x >> counter\nexport SENTINEL=ready\n")?; + let workspace_uri = PathUri::from_host_native_path(temp.path())?; + for index in 0..2 { + let session = backend + .start(ExecParams { + process_id: format!("bash-snapshot-{index}").into(), + argv: vec![ + "/bin/bash".into(), + "-c".into(), + "printf %s \"$SENTINEL\"".into(), + ], + cwd: workspace_uri.clone(), + env_policy: None, + env: HashMap::from([("BASH_ENV".to_string(), bash_env.display().to_string())]), + tty: false, + pipe_stdin: false, + arg0: None, + sandbox: None, + enforce_managed_network: false, + managed_network: None, + bash_env_snapshot: Some(BashEnvSnapshotParams { + workspace_root: workspace_uri.clone(), + preserve_env_keys: Vec::new(), + }), + }) + .await?; + let wake = session.process.subscribe_wake(); + let (output, ..) = collect_process_output_from_reads(session.process, wake).await?; + assert_eq!(output, "ready"); + } + assert_eq!(std::fs::read_to_string(temp.path().join("counter"))?, "x"); + Ok(()) +} + #[test_case(false ; "local")] #[test_case(true ; "remote")] #[cfg_attr(not(unix), ignore = "Unix-only exec-server process test")] diff --git a/codex-rs/exec-server/tests/relay.rs b/codex-rs/exec-server/tests/relay.rs index 918cba7393..3f524ac7e6 100644 --- a/codex-rs/exec-server/tests/relay.rs +++ b/codex-rs/exec-server/tests/relay.rs @@ -151,6 +151,7 @@ async fn remote_environment_routes_encrypted_exec_server_rpc() -> Result<()> { pipe_stdin: false, arg0: None, sandbox: None, + bash_env_snapshot: None, enforce_managed_network: false, managed_network: None, }) diff --git a/codex-rs/rmcp-client/src/stdio_server_launcher.rs b/codex-rs/rmcp-client/src/stdio_server_launcher.rs index 7d03e1437e..7107e21f30 100644 --- a/codex-rs/rmcp-client/src/stdio_server_launcher.rs +++ b/codex-rs/rmcp-client/src/stdio_server_launcher.rs @@ -508,6 +508,7 @@ impl ExecutorStdioServerLauncher { pipe_stdin: true, arg0: None, sandbox: None, + bash_env_snapshot: None, enforce_managed_network: false, managed_network: None, })