feat(core): zsh exec bridge (#12052)

zsh fork PR stack:
- https://github.com/openai/codex/pull/12051 
- https://github.com/openai/codex/pull/12052 👈 

### Summary
This PR introduces a feature-gated native shell runtime path that routes
shell execution through a patched zsh exec bridge, removing MCP-specific
behavior from the shell hot path while preserving existing
CommandExecution lifecycle semantics.

When shell_zsh_fork is enabled, shell commands run via patched zsh with
per-`execve` interception through EXEC_WRAPPER. Core receives wrapper
IPC requests over a Unix socket, applies existing approval policy, and
returns allow/deny before the subcommand executes.

### What’s included
**1) New zsh exec bridge runtime in core**
- Wrapper-mode entrypoint (maybe_run_zsh_exec_wrapper_mode) for
EXEC_WRAPPER invocations.
- Per-execution Unix-socket IPC handling for wrapper requests/responses.
- Approval callback integration using existing core approval
orchestration.
- Streaming stdout/stderr deltas to existing command output event
pipeline.
- Error handling for malformed IPC, denial/abort, and execution
failures.

**2) Session lifecycle integration**
SessionServices now owns a `ZshExecBridge`.
Session startup initializes bridge state; shutdown tears it down
cleanly.

**3) Shell runtime routing (feature-gated)**
When `shell_zsh_fork` is enabled:
- Build execution env/spec as usual.
- Add wrapper socket env wiring.
- Execute via `zsh_exec_bridge.execute_shell_request(...)` instead of
the regular shell path.
- Non-zsh-fork behavior remains unchanged.

**4) Config + feature wiring**
- Added `Feature::ShellZshFork` (under development).
- Added config support for `zsh_path` (optional absolute path to patched
zsh):
- `Config`, `ConfigToml`, `ConfigProfile`, overrides, and schema.
- Session startup validates that `zsh_path` exists/usable when zsh-fork
is enabled.
- Added startup test for missing `zsh_path` failure mode.

**5) Seatbelt/sandbox updates for wrapper IPC**
- Extended seatbelt policy generation to optionally allow outbound
connection to explicitly permitted Unix sockets.
- Wired sandboxing path to pass wrapper socket path through to seatbelt
policy generation.
- Added/updated seatbelt tests for explicit socket allow rule and
argument emission.

**6) Runtime entrypoint hooks**
- This allows the same binary to act as the zsh wrapper subprocess when
invoked via `EXEC_WRAPPER`.

**7) Tool selection behavior**
- ToolsConfig now prefers ShellCommand type when shell_zsh_fork is
enabled.
- Added test coverage for precedence with unified-exec enabled.
This commit is contained in:
Owen Lin
2026-02-17 20:19:53 -08:00
committed by GitHub
Unverified
parent fc810ba045
commit edacbf7b6e
19 changed files with 1735 additions and 23 deletions
+102 -1
View File
@@ -241,6 +241,7 @@ use crate::turn_diff_tracker::TurnDiffTracker;
use crate::unified_exec::UnifiedExecProcessManager;
use crate::util::backoff;
use crate::windows_sandbox::WindowsSandboxLevelExt;
use crate::zsh_exec_bridge::ZshExecBridge;
use codex_async_utils::OrCancelExt;
use codex_otel::OtelManager;
use codex_otel::TelemetryAuthMode;
@@ -1191,7 +1192,22 @@ impl Session {
config.active_profile.clone(),
);
let mut default_shell = shell::default_user_shell();
let use_zsh_fork_shell = config.features.enabled(Feature::ShellZshFork);
let mut default_shell = if use_zsh_fork_shell {
let zsh_path = config.zsh_path.as_ref().ok_or_else(|| {
anyhow::anyhow!(
"zsh fork feature enabled, but `zsh_path` is not configured; set `zsh_path` in config.toml"
)
})?;
shell::get_shell(shell::ShellType::Zsh, Some(zsh_path)).ok_or_else(|| {
anyhow::anyhow!(
"zsh fork feature enabled, but zsh_path `{}` is not usable; set `zsh_path` to a valid zsh executable",
zsh_path.display()
)
})?
} else {
shell::default_user_shell()
};
// Create the mutable state for the Session.
let shell_snapshot_tx = if config.features.enabled(Feature::ShellSnapshot) {
ShellSnapshot::start_snapshotting(
@@ -1262,10 +1278,17 @@ impl Session {
(None, None)
};
let zsh_exec_bridge =
ZshExecBridge::new(config.zsh_path.clone(), config.codex_home.clone());
zsh_exec_bridge
.initialize_for_session(&conversation_id.to_string())
.await;
let services = SessionServices {
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())),
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
unified_exec_manager: UnifiedExecProcessManager::default(),
zsh_exec_bridge,
analytics_events_client: AnalyticsEventsClient::new(
Arc::clone(&config),
Arc::clone(&auth_manager),
@@ -3969,6 +3992,7 @@ mod handlers {
.unified_exec_manager
.terminate_all_processes()
.await;
sess.services.zsh_exec_bridge.shutdown().await;
info!("Shutting down Codex instance");
let history = sess.clone_history().await;
let turn_count = history
@@ -7201,6 +7225,81 @@ mod tests {
}
}
#[tokio::test]
async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
let codex_home = tempfile::tempdir().expect("create temp dir");
let mut config = build_test_config(codex_home.path()).await;
config.features.enable(Feature::ShellZshFork);
config.zsh_path = None;
let config = Arc::new(config);
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
let models_manager = Arc::new(ModelsManager::new(
config.codex_home.clone(),
auth_manager.clone(),
));
let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref());
let model_info =
ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config);
let collaboration_mode = CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
model,
reasoning_effort: config.model_reasoning_effort,
developer_instructions: None,
},
};
let session_configuration = SessionConfiguration {
provider: config.model_provider.clone(),
collaboration_mode,
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
user_instructions: config.user_instructions.clone(),
personality: config.personality,
base_instructions: config
.base_instructions
.clone()
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.permissions.approval_policy.clone(),
sandbox_policy: config.permissions.sandbox_policy.clone(),
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
thread_name: None,
original_config_do_not_use: Arc::clone(&config),
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
};
let (tx_event, _rx_event) = async_channel::unbounded();
let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit);
let result = Session::new(
session_configuration,
Arc::clone(&config),
auth_manager,
models_manager,
ExecPolicyManager::default(),
tx_event,
agent_status_tx,
InitialHistory::New,
SessionSource::Exec,
Arc::new(SkillsManager::new(config.codex_home.clone())),
Arc::new(FileWatcher::noop()),
AgentControl::default(),
)
.await;
let err = match result {
Ok(_) => panic!("expected startup to fail"),
Err(err) => err,
};
let msg = format!("{err:#}");
assert!(msg.contains("zsh fork feature enabled, but `zsh_path` is not configured"));
}
// todo: use online model info
pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
let (tx_event, _rx_event) = async_channel::unbounded();
@@ -7274,6 +7373,7 @@ mod tests {
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())),
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
unified_exec_manager: UnifiedExecProcessManager::default(),
zsh_exec_bridge: ZshExecBridge::default(),
analytics_events_client: AnalyticsEventsClient::new(
Arc::clone(&config),
Arc::clone(&auth_manager),
@@ -7422,6 +7522,7 @@ mod tests {
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())),
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
unified_exec_manager: UnifiedExecProcessManager::default(),
zsh_exec_bridge: ZshExecBridge::default(),
analytics_events_client: AnalyticsEventsClient::new(
Arc::clone(&config),
Arc::clone(&auth_manager),
+14
View File
@@ -338,6 +338,8 @@ pub struct Config {
/// Optional absolute path to the Node runtime used by `js_repl`.
pub js_repl_node_path: Option<PathBuf>,
/// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution.
pub zsh_path: Option<PathBuf>,
/// Value to use for `reasoning.effort` when making a request using the
/// Responses API.
@@ -977,6 +979,8 @@ pub struct ConfigToml {
/// Optional absolute path to the Node runtime used by `js_repl`.
pub js_repl_node_path: Option<AbsolutePathBuf>,
/// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution.
pub zsh_path: Option<AbsolutePathBuf>,
/// Profile to use from the `profiles` map.
pub profile: Option<String>,
@@ -1355,6 +1359,7 @@ pub struct ConfigOverrides {
pub config_profile: Option<String>,
pub codex_linux_sandbox_exe: Option<PathBuf>,
pub js_repl_node_path: Option<PathBuf>,
pub zsh_path: Option<PathBuf>,
pub base_instructions: Option<String>,
pub developer_instructions: Option<String>,
pub personality: Option<Personality>,
@@ -1482,6 +1487,7 @@ impl Config {
config_profile: config_profile_key,
codex_linux_sandbox_exe,
js_repl_node_path: js_repl_node_path_override,
zsh_path: zsh_path_override,
base_instructions,
developer_instructions,
personality,
@@ -1742,6 +1748,9 @@ impl Config {
let js_repl_node_path = js_repl_node_path_override
.or(config_profile.js_repl_node_path.map(Into::into))
.or(cfg.js_repl_node_path.map(Into::into));
let zsh_path = zsh_path_override
.or(config_profile.zsh_path.map(Into::into))
.or(cfg.zsh_path.map(Into::into));
let review_model = override_review_model.or(cfg.review_model);
@@ -1866,6 +1875,7 @@ impl Config {
file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode),
codex_linux_sandbox_exe,
js_repl_node_path,
zsh_path,
hide_agent_reasoning: cfg.hide_agent_reasoning.unwrap_or(false),
show_raw_agent_reasoning: cfg
@@ -4196,6 +4206,7 @@ model_verbosity = "high"
file_opener: UriBasedFileOpener::VsCode,
codex_linux_sandbox_exe: None,
js_repl_node_path: None,
zsh_path: None,
hide_agent_reasoning: false,
show_raw_agent_reasoning: false,
model_reasoning_effort: Some(ReasoningEffort::High),
@@ -4309,6 +4320,7 @@ model_verbosity = "high"
file_opener: UriBasedFileOpener::VsCode,
codex_linux_sandbox_exe: None,
js_repl_node_path: None,
zsh_path: None,
hide_agent_reasoning: false,
show_raw_agent_reasoning: false,
model_reasoning_effort: None,
@@ -4420,6 +4432,7 @@ model_verbosity = "high"
file_opener: UriBasedFileOpener::VsCode,
codex_linux_sandbox_exe: None,
js_repl_node_path: None,
zsh_path: None,
hide_agent_reasoning: false,
show_raw_agent_reasoning: false,
model_reasoning_effort: None,
@@ -4517,6 +4530,7 @@ model_verbosity = "high"
file_opener: UriBasedFileOpener::VsCode,
codex_linux_sandbox_exe: None,
js_repl_node_path: None,
zsh_path: None,
hide_agent_reasoning: false,
show_raw_agent_reasoning: false,
model_reasoning_effort: Some(ReasoningEffort::High),
+2
View File
@@ -30,6 +30,8 @@ pub struct ConfigProfile {
pub chatgpt_base_url: Option<String>,
/// Optional path to a file containing model instructions.
pub model_instructions_file: Option<AbsolutePathBuf>,
/// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution.
pub zsh_path: Option<AbsolutePathBuf>,
pub js_repl_node_path: Option<AbsolutePathBuf>,
/// Deprecated: ignored. Use `model_instructions_file`.
#[schemars(skip)]
+2 -2
View File
@@ -75,7 +75,7 @@ pub struct ExecParams {
}
/// Mechanism to terminate an exec invocation before it finishes naturally.
#[derive(Debug)]
#[derive(Clone, Debug)]
pub enum ExecExpiration {
Timeout(Duration),
DefaultTimeout,
@@ -97,7 +97,7 @@ impl From<u64> for ExecExpiration {
}
impl ExecExpiration {
async fn wait(self) {
pub(crate) async fn wait(self) {
match self {
ExecExpiration::Timeout(duration) => tokio::time::sleep(duration).await,
ExecExpiration::DefaultTimeout => {
+8
View File
@@ -84,6 +84,8 @@ pub enum Feature {
JsReplToolsOnly,
/// Use the single unified PTY-backed exec tool.
UnifiedExec,
/// Route shell tool execution through the zsh exec bridge.
ShellZshFork,
/// Include the freeform apply_patch tool.
ApplyPatchFreeform,
/// Allow the model to request web searches that fetch live content.
@@ -431,6 +433,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: !cfg!(windows),
},
FeatureSpec {
id: Feature::ShellZshFork,
key: "shell_zsh_fork",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::ShellSnapshot,
key: "shell_snapshot",
+2
View File
@@ -109,6 +109,7 @@ pub mod terminal;
mod tools;
pub mod turn_diff_tracker;
mod turn_metadata;
mod zsh_exec_bridge;
pub use rollout::ARCHIVED_SESSIONS_SUBDIR;
pub use rollout::INTERACTIVE_SESSION_SOURCES;
pub use rollout::RolloutRecorder;
@@ -152,6 +153,7 @@ pub use file_watcher::FileWatcherEvent;
pub use safety::get_platform_sandbox;
pub use tools::spec::parse_tool_input_schema;
pub use turn_metadata::build_turn_metadata_header;
pub use zsh_exec_bridge::maybe_run_zsh_exec_wrapper_mode;
// Re-export the protocol types from the standalone `codex-protocol` crate so existing
// `codex_core::protocol::...` references continue to work across the workspace.
pub use codex_protocol::protocol;
+7
View File
@@ -164,12 +164,19 @@ impl SandboxManager {
SandboxType::MacosSeatbelt => {
let mut seatbelt_env = HashMap::new();
seatbelt_env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
let zsh_exec_bridge_wrapper_socket = env
.get(crate::zsh_exec_bridge::ZSH_EXEC_BRIDGE_WRAPPER_SOCKET_ENV_VAR)
.map(PathBuf::from);
let zsh_exec_bridge_allowed_unix_sockets = zsh_exec_bridge_wrapper_socket
.as_ref()
.map_or_else(Vec::new, |path| vec![path.clone()]);
let mut args = create_seatbelt_command_args(
command.clone(),
policy,
sandbox_policy_cwd,
enforce_managed_network,
network,
&zsh_exec_bridge_allowed_unix_sockets,
);
let mut full_command = Vec::with_capacity(1 + args.len());
full_command.push(MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string());
+84 -17
View File
@@ -39,8 +39,14 @@ pub async fn spawn_command_under_seatbelt(
network: Option<&NetworkProxy>,
mut env: HashMap<String, String>,
) -> std::io::Result<Child> {
let args =
create_seatbelt_command_args(command, sandbox_policy, sandbox_policy_cwd, false, network);
let args = create_seatbelt_command_args(
command,
sandbox_policy,
sandbox_policy_cwd,
false,
network,
&[],
);
let arg0 = None;
env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
spawn_child_async(SpawnChildRequest {
@@ -181,6 +187,7 @@ pub(crate) fn create_seatbelt_command_args(
sandbox_policy_cwd: &Path,
enforce_managed_network: bool,
network: Option<&NetworkProxy>,
allowed_unix_socket_paths: &[PathBuf],
) -> Vec<String> {
create_seatbelt_command_args_with_extensions(
command,
@@ -189,6 +196,7 @@ pub(crate) fn create_seatbelt_command_args(
enforce_managed_network,
network,
None,
allowed_unix_socket_paths,
)
}
@@ -199,6 +207,7 @@ pub(crate) fn create_seatbelt_command_args_with_extensions(
enforce_managed_network: bool,
network: Option<&NetworkProxy>,
extensions: Option<&MacOsSeatbeltProfileExtensions>,
allowed_unix_socket_paths: &[PathBuf],
) -> Vec<String> {
let (file_write_policy, file_write_dir_params) = {
if sandbox_policy.has_full_disk_write_access() {
@@ -295,6 +304,8 @@ pub(crate) fn create_seatbelt_command_args_with_extensions(
let proxy = proxy_policy_inputs(network);
let network_policy = dynamic_network_policy(sandbox_policy, enforce_managed_network, &proxy);
let (unix_socket_policy, unix_socket_params) =
unix_socket_policy_and_params(allowed_unix_socket_paths);
let seatbelt_extensions = extensions.map_or_else(
|| {
// Backward-compatibility default when no extension profile is provided.
@@ -303,20 +314,24 @@ pub(crate) fn create_seatbelt_command_args_with_extensions(
build_seatbelt_extensions,
);
let full_policy = if seatbelt_extensions.policy.is_empty() {
format!(
"{MACOS_SEATBELT_BASE_POLICY}\n{file_read_policy}\n{file_write_policy}\n{network_policy}"
)
} else {
format!(
"{MACOS_SEATBELT_BASE_POLICY}\n{file_read_policy}\n{file_write_policy}\n{network_policy}\n{}",
seatbelt_extensions.policy
)
};
let mut policy_sections = vec![
MACOS_SEATBELT_BASE_POLICY.to_string(),
file_read_policy,
file_write_policy,
network_policy,
];
if !unix_socket_policy.is_empty() {
policy_sections.push(unix_socket_policy);
}
if !seatbelt_extensions.policy.is_empty() {
policy_sections.push(seatbelt_extensions.policy.clone());
}
let full_policy = policy_sections.join("\n");
let dir_params = [
file_read_dir_params,
file_write_dir_params,
unix_socket_params,
macos_dir_params(),
seatbelt_extensions.dir_params,
]
@@ -332,6 +347,27 @@ pub(crate) fn create_seatbelt_command_args_with_extensions(
seatbelt_args
}
fn unix_socket_policy_and_params(
allowed_unix_socket_paths: &[PathBuf],
) -> (String, Vec<(String, PathBuf)>) {
if allowed_unix_socket_paths.is_empty() {
return (String::new(), Vec::new());
}
let mut policy = String::from("; allow outbound connect to explicitly-approved unix sockets\n");
let mut params = Vec::with_capacity(allowed_unix_socket_paths.len());
for (index, path) in allowed_unix_socket_paths.iter().enumerate() {
let key = format!("ALLOWED_UNIX_SOCKET_{index}");
policy.push_str(&format!(
"(allow network-outbound (remote unix-socket (path (param \"{key}\"))))\n"
));
params.push((key, path.clone()));
}
(policy, params)
}
/// Wraps libc::confstr to return a String.
fn confstr(name: libc::c_int) -> Option<String> {
let mut buf = vec![0_i8; (libc::PATH_MAX as usize) + 1];
@@ -451,6 +487,7 @@ mod tests {
macos_accessibility: true,
macos_calendar: true,
}),
&[],
);
let policy = &args[1];
@@ -469,6 +506,7 @@ mod tests {
cwd.as_path(),
false,
None,
&[],
);
let policy = &args[1];
assert!(policy.contains("(allow user-preference-read)"));
@@ -485,6 +523,7 @@ mod tests {
false,
None,
Some(&MacOsSeatbeltProfileExtensions::default()),
&[],
);
let policy = &args[1];
assert!(!policy.contains("appleevent-send"));
@@ -494,6 +533,32 @@ mod tests {
assert!(!policy.contains("user-preference-write"));
}
#[test]
fn seatbelt_args_allow_explicit_wrapper_unix_socket_path() {
let cwd = std::env::temp_dir();
let socket_path = std::env::temp_dir().join("codex-zsh-wrapper-test.sock");
let args = create_seatbelt_command_args(
vec!["echo".to_string(), "ok".to_string()],
&SandboxPolicy::new_read_only_policy(),
cwd.as_path(),
false,
None,
std::slice::from_ref(&socket_path),
);
let policy = &args[1];
let param_key = "ALLOWED_UNIX_SOCKET_0";
assert!(
policy.contains("(allow network-outbound (remote unix-socket (path (param \"ALLOWED_UNIX_SOCKET_0\"))))"),
"policy should contain explicit unix-socket allow rule:\n{policy}"
);
assert!(
args.iter()
.any(|arg| arg == &format!("-D{param_key}={}", socket_path.to_string_lossy())),
"args should include unix-socket path param"
);
}
#[test]
fn create_seatbelt_args_allows_local_binding_when_explicitly_enabled() {
let policy = dynamic_network_policy(
@@ -649,7 +714,8 @@ mod tests {
.iter()
.map(std::string::ToString::to_string)
.collect();
let args = create_seatbelt_command_args(shell_command.clone(), &policy, &cwd, false, None);
let args =
create_seatbelt_command_args(shell_command.clone(), &policy, &cwd, false, None, &[]);
// Build the expected policy text using a raw string for readability.
// Note that the policy includes:
@@ -746,7 +812,7 @@ mod tests {
.map(std::string::ToString::to_string)
.collect();
let write_hooks_file_args =
create_seatbelt_command_args(shell_command_git, &policy, &cwd, false, None);
create_seatbelt_command_args(shell_command_git, &policy, &cwd, false, None, &[]);
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
.args(&write_hooks_file_args)
.current_dir(&cwd)
@@ -777,7 +843,7 @@ mod tests {
.map(std::string::ToString::to_string)
.collect();
let write_allowed_file_args =
create_seatbelt_command_args(shell_command_allowed, &policy, &cwd, false, None);
create_seatbelt_command_args(shell_command_allowed, &policy, &cwd, false, None, &[]);
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
.args(&write_allowed_file_args)
.current_dir(&cwd)
@@ -838,7 +904,7 @@ mod tests {
.iter()
.map(std::string::ToString::to_string)
.collect();
let args = create_seatbelt_command_args(shell_command, &policy, &cwd, false, None);
let args = create_seatbelt_command_args(shell_command, &policy, &cwd, false, None, &[]);
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
.args(&args)
@@ -869,7 +935,7 @@ mod tests {
.map(std::string::ToString::to_string)
.collect();
let gitdir_args =
create_seatbelt_command_args(shell_command_gitdir, &policy, &cwd, false, None);
create_seatbelt_command_args(shell_command_gitdir, &policy, &cwd, false, None, &[]);
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
.args(&gitdir_args)
.current_dir(&cwd)
@@ -932,6 +998,7 @@ mod tests {
vulnerable_root.as_path(),
false,
None,
&[],
);
let tmpdir_env_var = std::env::var("TMPDIR")
+2
View File
@@ -15,6 +15,7 @@ use crate::state_db::StateDbHandle;
use crate::tools::network_approval::NetworkApprovalService;
use crate::tools::sandboxing::ApprovalStore;
use crate::unified_exec::UnifiedExecProcessManager;
use crate::zsh_exec_bridge::ZshExecBridge;
use codex_hooks::Hooks;
use codex_otel::OtelManager;
use tokio::sync::Mutex;
@@ -26,6 +27,7 @@ pub(crate) struct SessionServices {
pub(crate) mcp_connection_manager: Arc<RwLock<McpConnectionManager>>,
pub(crate) mcp_startup_cancellation_token: Mutex<CancellationToken>,
pub(crate) unified_exec_manager: UnifiedExecProcessManager,
pub(crate) zsh_exec_bridge: ZshExecBridge,
pub(crate) analytics_events_client: AnalyticsEventsClient,
pub(crate) hooks: Hooks,
pub(crate) rollout: Mutex<Option<RolloutRecorder>>,
+31
View File
@@ -26,6 +26,7 @@ use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::with_cached_approval;
use crate::zsh_exec_bridge::ZSH_EXEC_BRIDGE_WRAPPER_SOCKET_ENV_VAR;
use codex_network_proxy::NetworkProxy;
use codex_protocol::protocol::ReviewDecision;
use futures::future::BoxFuture;
@@ -182,6 +183,36 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
command
};
if ctx.session.features().enabled(Feature::ShellZshFork) {
let wrapper_socket_path = ctx
.session
.services
.zsh_exec_bridge
.next_wrapper_socket_path();
let mut zsh_fork_env = req.env.clone();
zsh_fork_env.insert(
ZSH_EXEC_BRIDGE_WRAPPER_SOCKET_ENV_VAR.to_string(),
wrapper_socket_path.to_string_lossy().to_string(),
);
let spec = build_command_spec(
&command,
&req.cwd,
&zsh_fork_env,
req.timeout_ms.into(),
req.sandbox_permissions,
req.justification.clone(),
)?;
let env = attempt
.env_for(spec, req.network.as_ref())
.map_err(|err| ToolError::Codex(err.into()))?;
return ctx
.session
.services
.zsh_exec_bridge
.execute_shell_request(&env, ctx.session, ctx.turn, &ctx.call_id)
.await;
}
let spec = build_command_spec(
&command,
&req.cwd,
+19
View File
@@ -69,6 +69,8 @@ impl ToolsConfig {
let shell_type = if !features.enabled(Feature::ShellTool) {
ConfigShellToolType::Disabled
} else if features.enabled(Feature::ShellZshFork) {
ConfigShellToolType::ShellCommand
} else if features.enabled(Feature::UnifiedExec) {
// If ConPTY not supported (for old Windows versions), fallback on ShellCommand.
if codex_utils_pty::conpty_supported() {
@@ -2346,6 +2348,23 @@ mod tests {
assert_contains_tool_names(&tools, &subset);
}
#[test]
fn shell_zsh_fork_prefers_shell_command_over_unified_exec() {
let config = test_config();
let model_info = ModelsManager::construct_model_info_offline_for_tests("o3", &config);
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
features.enable(Feature::ShellZshFork);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Live),
});
assert_eq!(tools_config.shell_type, ConfigShellToolType::ShellCommand);
}
#[test]
#[ignore]
fn test_parallel_support_flags() {
+554
View File
@@ -0,0 +1,554 @@
use crate::exec::ExecToolCallOutput;
use crate::tools::sandboxing::ToolError;
use std::path::PathBuf;
use tokio::sync::Mutex;
use uuid::Uuid;
#[cfg(unix)]
use crate::error::CodexErr;
#[cfg(unix)]
use crate::error::SandboxErr;
#[cfg(unix)]
use crate::protocol::EventMsg;
#[cfg(unix)]
use crate::protocol::ExecCommandOutputDeltaEvent;
#[cfg(unix)]
use crate::protocol::ExecOutputStream;
#[cfg(unix)]
use crate::protocol::ReviewDecision;
#[cfg(unix)]
use anyhow::Context as _;
#[cfg(unix)]
use codex_protocol::approvals::ExecPolicyAmendment;
#[cfg(unix)]
use codex_utils_pty::process_group::kill_child_process_group;
#[cfg(unix)]
use serde::Deserialize;
#[cfg(unix)]
use serde::Serialize;
#[cfg(unix)]
use std::io::Read;
#[cfg(unix)]
use std::io::Write;
#[cfg(unix)]
use std::time::Instant;
#[cfg(unix)]
use tokio::io::AsyncReadExt;
#[cfg(unix)]
use tokio::net::UnixListener;
#[cfg(unix)]
use tokio::net::UnixStream;
pub(crate) const ZSH_EXEC_BRIDGE_WRAPPER_SOCKET_ENV_VAR: &str =
"CODEX_ZSH_EXEC_BRIDGE_WRAPPER_SOCKET";
pub(crate) const ZSH_EXEC_WRAPPER_MODE_ENV_VAR: &str = "CODEX_ZSH_EXEC_WRAPPER_MODE";
#[cfg(unix)]
pub(crate) const EXEC_WRAPPER_ENV_VAR: &str = "EXEC_WRAPPER";
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct ZshExecBridgeSessionState {
pub(crate) initialized_session_id: Option<String>,
}
#[derive(Debug, Default)]
pub(crate) struct ZshExecBridge {
zsh_path: Option<PathBuf>,
state: Mutex<ZshExecBridgeSessionState>,
}
#[cfg(unix)]
#[derive(Debug, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum WrapperIpcRequest {
ExecRequest {
request_id: String,
file: String,
argv: Vec<String>,
cwd: String,
},
}
#[cfg(unix)]
#[derive(Debug, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum WrapperIpcResponse {
ExecResponse {
request_id: String,
action: WrapperExecAction,
reason: Option<String>,
},
}
#[cfg(unix)]
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
enum WrapperExecAction {
Run,
Deny,
}
impl ZshExecBridge {
pub(crate) fn new(zsh_path: Option<PathBuf>, _codex_home: PathBuf) -> Self {
Self {
zsh_path,
state: Mutex::new(ZshExecBridgeSessionState::default()),
}
}
pub(crate) async fn initialize_for_session(&self, session_id: &str) {
let mut state = self.state.lock().await;
state.initialized_session_id = Some(session_id.to_string());
}
pub(crate) async fn shutdown(&self) {
let mut state = self.state.lock().await;
state.initialized_session_id = None;
}
pub(crate) fn next_wrapper_socket_path(&self) -> PathBuf {
let socket_id = Uuid::new_v4().as_simple().to_string();
let temp_dir = std::env::temp_dir();
let canonical_temp_dir = temp_dir.canonicalize().unwrap_or(temp_dir);
canonical_temp_dir.join(format!("czs-{}.sock", &socket_id[..12]))
}
#[cfg(not(unix))]
pub(crate) async fn execute_shell_request(
&self,
_req: &crate::sandboxing::ExecRequest,
_session: &crate::codex::Session,
_turn: &crate::codex::TurnContext,
_call_id: &str,
) -> Result<ExecToolCallOutput, ToolError> {
let _ = &self.zsh_path;
Err(ToolError::Rejected(
"shell_zsh_fork is only supported on unix".to_string(),
))
}
#[cfg(unix)]
pub(crate) async fn execute_shell_request(
&self,
req: &crate::sandboxing::ExecRequest,
session: &crate::codex::Session,
turn: &crate::codex::TurnContext,
call_id: &str,
) -> Result<ExecToolCallOutput, ToolError> {
let zsh_path = self.zsh_path.clone().ok_or_else(|| {
ToolError::Rejected(
"shell_zsh_fork enabled, but zsh_path is not configured".to_string(),
)
})?;
let command = req.command.clone();
if command.is_empty() {
return Err(ToolError::Rejected("command args are empty".to_string()));
}
let wrapper_socket_path = req
.env
.get(ZSH_EXEC_BRIDGE_WRAPPER_SOCKET_ENV_VAR)
.map(PathBuf::from)
.unwrap_or_else(|| self.next_wrapper_socket_path());
let listener = {
let _ = std::fs::remove_file(&wrapper_socket_path);
UnixListener::bind(&wrapper_socket_path).map_err(|err| {
ToolError::Rejected(format!(
"bind wrapper socket at {}: {err}",
wrapper_socket_path.display()
))
})?
};
let wrapper_path = std::env::current_exe().map_err(|err| {
ToolError::Rejected(format!("resolve current executable path: {err}"))
})?;
let mut cmd = tokio::process::Command::new(&command[0]);
if command.len() > 1 {
cmd.args(&command[1..]);
}
cmd.current_dir(&req.cwd);
cmd.stdin(std::process::Stdio::null());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
cmd.kill_on_drop(true);
cmd.env_clear();
cmd.envs(&req.env);
cmd.env(
ZSH_EXEC_BRIDGE_WRAPPER_SOCKET_ENV_VAR,
wrapper_socket_path.to_string_lossy().to_string(),
);
cmd.env(EXEC_WRAPPER_ENV_VAR, &wrapper_path);
cmd.env(ZSH_EXEC_WRAPPER_MODE_ENV_VAR, "1");
let mut child = cmd.spawn().map_err(|err| {
ToolError::Rejected(format!(
"failed to start zsh fork command {} with zsh_path {}: {err}",
command[0],
zsh_path.display()
))
})?;
let (stream_tx, mut stream_rx) =
tokio::sync::mpsc::unbounded_channel::<(ExecOutputStream, Vec<u8>)>();
if let Some(mut out) = child.stdout.take() {
let tx = stream_tx.clone();
tokio::spawn(async move {
let mut buf = [0_u8; 8192];
loop {
let read = match out.read(&mut buf).await {
Ok(0) => break,
Ok(n) => n,
Err(err) => {
tracing::warn!("zsh fork stdout read error: {err}");
break;
}
};
let _ = tx.send((ExecOutputStream::Stdout, buf[..read].to_vec()));
}
});
}
if let Some(mut err) = child.stderr.take() {
let tx = stream_tx.clone();
tokio::spawn(async move {
let mut buf = [0_u8; 8192];
loop {
let read = match err.read(&mut buf).await {
Ok(0) => break,
Ok(n) => n,
Err(err) => {
tracing::warn!("zsh fork stderr read error: {err}");
break;
}
};
let _ = tx.send((ExecOutputStream::Stderr, buf[..read].to_vec()));
}
});
}
drop(stream_tx);
let mut stdout_bytes = Vec::new();
let mut stderr_bytes = Vec::new();
let mut child_exit = None;
let mut timed_out = false;
let mut stream_open = true;
let mut user_rejected = false;
let start = Instant::now();
let expiration = req.expiration.clone().wait();
tokio::pin!(expiration);
while child_exit.is_none() || stream_open {
tokio::select! {
result = child.wait(), if child_exit.is_none() => {
child_exit = Some(result.map_err(|err| ToolError::Rejected(format!("wait for zsh fork command exit: {err}")))?);
}
stream = stream_rx.recv(), if stream_open => {
if let Some((output_stream, chunk)) = stream {
match output_stream {
ExecOutputStream::Stdout => stdout_bytes.extend_from_slice(&chunk),
ExecOutputStream::Stderr => stderr_bytes.extend_from_slice(&chunk),
}
session
.send_event(
turn,
EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent {
call_id: call_id.to_string(),
stream: output_stream,
chunk,
}),
)
.await;
} else {
stream_open = false;
}
}
accept_result = listener.accept(), if child_exit.is_none() => {
let (stream, _) = accept_result.map_err(|err| {
ToolError::Rejected(format!("failed to accept wrapper request: {err}"))
})?;
if self
.handle_wrapper_request(stream, req.justification.clone(), session, turn, call_id)
.await?
{
user_rejected = true;
}
}
_ = &mut expiration, if child_exit.is_none() => {
timed_out = true;
kill_child_process_group(&mut child).map_err(|err| {
ToolError::Rejected(format!("kill zsh fork command process group: {err}"))
})?;
child.start_kill().map_err(|err| {
ToolError::Rejected(format!("kill zsh fork command process: {err}"))
})?;
}
}
}
let _ = std::fs::remove_file(&wrapper_socket_path);
let status = child_exit.ok_or_else(|| {
ToolError::Rejected("zsh fork command did not return exit status".to_string())
})?;
if user_rejected {
return Err(ToolError::Rejected("rejected by user".to_string()));
}
let stdout_text = crate::text_encoding::bytes_to_string_smart(&stdout_bytes);
let stderr_text = crate::text_encoding::bytes_to_string_smart(&stderr_bytes);
let output = ExecToolCallOutput {
exit_code: status.code().unwrap_or(-1),
stdout: crate::exec::StreamOutput::new(stdout_text.clone()),
stderr: crate::exec::StreamOutput::new(stderr_text.clone()),
aggregated_output: crate::exec::StreamOutput::new(format!(
"{stdout_text}{stderr_text}"
)),
duration: start.elapsed(),
timed_out,
};
Self::map_exec_result(req.sandbox, output)
}
#[cfg(unix)]
async fn handle_wrapper_request(
&self,
mut stream: UnixStream,
approval_reason: Option<String>,
session: &crate::codex::Session,
turn: &crate::codex::TurnContext,
call_id: &str,
) -> Result<bool, ToolError> {
let mut request_buf = Vec::new();
stream.read_to_end(&mut request_buf).await.map_err(|err| {
ToolError::Rejected(format!("read wrapper request from socket: {err}"))
})?;
let request_line = String::from_utf8(request_buf).map_err(|err| {
ToolError::Rejected(format!("decode wrapper request as utf-8: {err}"))
})?;
let request = parse_wrapper_request_line(request_line.trim())?;
let (request_id, file, argv, cwd) = match request {
WrapperIpcRequest::ExecRequest {
request_id,
file,
argv,
cwd,
} => (request_id, file, argv, cwd),
};
let command_for_approval = if argv.is_empty() {
vec![file.clone()]
} else {
argv.clone()
};
let approval_id = Uuid::new_v4().to_string();
let decision = session
.request_command_approval(
turn,
call_id.to_string(),
Some(approval_id),
command_for_approval,
PathBuf::from(cwd),
approval_reason,
None,
None::<ExecPolicyAmendment>,
)
.await;
let (action, reason, user_rejected) = match decision {
ReviewDecision::Approved
| ReviewDecision::ApprovedForSession
| ReviewDecision::ApprovedExecpolicyAmendment { .. } => {
(WrapperExecAction::Run, None, false)
}
ReviewDecision::Denied => (
WrapperExecAction::Deny,
Some("command denied by host approval policy".to_string()),
true,
),
ReviewDecision::Abort => (
WrapperExecAction::Deny,
Some("command aborted by host approval policy".to_string()),
true,
),
};
write_json_line(
&mut stream,
&WrapperIpcResponse::ExecResponse {
request_id,
action,
reason,
},
)
.await?;
Ok(user_rejected)
}
#[cfg(unix)]
fn map_exec_result(
sandbox: crate::exec::SandboxType,
output: ExecToolCallOutput,
) -> Result<ExecToolCallOutput, ToolError> {
if output.timed_out {
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Timeout {
output: Box::new(output),
})));
}
if crate::exec::is_likely_sandbox_denied(sandbox, &output) {
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
output: Box::new(output),
network_policy_decision: None,
})));
}
Ok(output)
}
}
pub fn maybe_run_zsh_exec_wrapper_mode() -> anyhow::Result<bool> {
if std::env::var_os(ZSH_EXEC_WRAPPER_MODE_ENV_VAR).is_none() {
return Ok(false);
}
run_exec_wrapper_mode()?;
Ok(true)
}
fn run_exec_wrapper_mode() -> anyhow::Result<()> {
#[cfg(not(unix))]
{
anyhow::bail!("zsh exec wrapper mode is only supported on unix");
}
#[cfg(unix)]
{
use std::os::unix::net::UnixStream as StdUnixStream;
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 {
anyhow::bail!("exec wrapper mode requires target executable path");
}
let file = args[1].clone();
let argv = if args.len() > 2 {
args[2..].to_vec()
} else {
vec![file.clone()]
};
let cwd = std::env::current_dir()
.context("resolve wrapper cwd")?
.to_string_lossy()
.to_string();
let socket_path = std::env::var(ZSH_EXEC_BRIDGE_WRAPPER_SOCKET_ENV_VAR)
.context("missing wrapper socket path env var")?;
let request_id = Uuid::new_v4().to_string();
let request = WrapperIpcRequest::ExecRequest {
request_id: request_id.clone(),
file: file.clone(),
argv: argv.clone(),
cwd,
};
let mut stream = StdUnixStream::connect(&socket_path)
.with_context(|| format!("connect to wrapper socket at {socket_path}"))?;
let encoded = serde_json::to_string(&request).context("serialize wrapper request")?;
stream
.write_all(encoded.as_bytes())
.context("write wrapper request")?;
stream
.write_all(b"\n")
.context("write wrapper request newline")?;
stream
.shutdown(std::net::Shutdown::Write)
.context("shutdown wrapper write")?;
let mut response_buf = String::new();
stream
.read_to_string(&mut response_buf)
.context("read wrapper response")?;
let response: WrapperIpcResponse =
serde_json::from_str(response_buf.trim()).context("parse wrapper response")?;
let (response_request_id, action, reason) = match response {
WrapperIpcResponse::ExecResponse {
request_id,
action,
reason,
} => (request_id, action, reason),
};
if response_request_id != request_id {
anyhow::bail!(
"wrapper response request_id mismatch: expected {request_id}, got {response_request_id}"
);
}
if action == WrapperExecAction::Deny {
if let Some(reason) = reason {
tracing::warn!("execution denied: {reason}");
} else {
tracing::warn!("execution denied");
}
std::process::exit(1);
}
let mut command = std::process::Command::new(&file);
if argv.len() > 1 {
command.args(&argv[1..]);
}
command.env_remove(ZSH_EXEC_WRAPPER_MODE_ENV_VAR);
command.env_remove(ZSH_EXEC_BRIDGE_WRAPPER_SOCKET_ENV_VAR);
command.env_remove(EXEC_WRAPPER_ENV_VAR);
let status = command.status().context("spawn wrapped executable")?;
std::process::exit(status.code().unwrap_or(1));
}
}
#[cfg(unix)]
fn parse_wrapper_request_line(request_line: &str) -> Result<WrapperIpcRequest, ToolError> {
serde_json::from_str(request_line)
.map_err(|err| ToolError::Rejected(format!("parse wrapper request payload: {err}")))
}
#[cfg(unix)]
async fn write_json_line<W: tokio::io::AsyncWrite + Unpin, T: Serialize>(
writer: &mut W,
message: &T,
) -> Result<(), ToolError> {
let encoded = serde_json::to_string(message)
.map_err(|err| ToolError::Rejected(format!("serialize wrapper message: {err}")))?;
tokio::io::AsyncWriteExt::write_all(writer, encoded.as_bytes())
.await
.map_err(|err| ToolError::Rejected(format!("write wrapper message: {err}")))?;
tokio::io::AsyncWriteExt::write_all(writer, b"\n")
.await
.map_err(|err| ToolError::Rejected(format!("write wrapper newline: {err}")))?;
tokio::io::AsyncWriteExt::flush(writer)
.await
.map_err(|err| ToolError::Rejected(format!("flush wrapper message: {err}")))?;
Ok(())
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
#[test]
fn parse_wrapper_request_line_rejects_malformed_json() {
let err = parse_wrapper_request_line("this-is-not-json").unwrap_err();
let ToolError::Rejected(message) = err else {
panic!("expected ToolError::Rejected");
};
assert!(message.starts_with("parse wrapper request payload:"));
}
}