Skip to content

Commit 92b057f

Browse files
committed
feat(core): add shell zsh exec bridge runtime path
1 parent 9ecd1e9 commit 92b057f

File tree

15 files changed

+896
-18
lines changed

15 files changed

+896
-18
lines changed

codex-rs/app-server/src/main.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ struct AppServerArgs {
2323
}
2424

2525
fn main() -> anyhow::Result<()> {
26+
if codex_core::maybe_run_zsh_exec_wrapper_mode()? {
27+
return Ok(());
28+
}
2629
arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move {
2730
let args = AppServerArgs::parse();
2831
let managed_config_path = managed_config_path_from_debug_env();

codex-rs/cli/src/main.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,9 @@ fn stage_str(stage: codex_core::features::Stage) -> &'static str {
543543
}
544544

545545
fn main() -> anyhow::Result<()> {
546+
if codex_core::maybe_run_zsh_exec_wrapper_mode()? {
547+
return Ok(());
548+
}
546549
arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move {
547550
cli_main(codex_linux_sandbox_exe).await?;
548551
Ok(())

codex-rs/core/config.schema.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,9 @@
269269
"shell_tool": {
270270
"type": "boolean"
271271
},
272+
"shell_zsh_fork": {
273+
"type": "boolean"
274+
},
272275
"skill_env_var_dependency_prompt": {
273276
"type": "boolean"
274277
},
@@ -357,6 +360,14 @@
357360
}
358361
],
359362
"default": null
363+
},
364+
"zsh_path": {
365+
"allOf": [
366+
{
367+
"$ref": "#/definitions/AbsolutePathBuf"
368+
}
369+
],
370+
"description": "Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution."
360371
}
361372
},
362373
"type": "object"
@@ -1402,6 +1413,9 @@
14021413
"shell_tool": {
14031414
"type": "boolean"
14041415
},
1416+
"shell_zsh_fork": {
1417+
"type": "boolean"
1418+
},
14051419
"skill_env_var_dependency_prompt": {
14061420
"type": "boolean"
14071421
},
@@ -1758,6 +1772,14 @@
17581772
"windows_wsl_setup_acknowledged": {
17591773
"description": "Tracks whether the Windows onboarding screen has been acknowledged.",
17601774
"type": "boolean"
1775+
},
1776+
"zsh_path": {
1777+
"allOf": [
1778+
{
1779+
"$ref": "#/definitions/AbsolutePathBuf"
1780+
}
1781+
],
1782+
"description": "Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution."
17611783
}
17621784
},
17631785
"title": "ConfigToml",

codex-rs/core/src/codex.rs

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ use crate::turn_diff_tracker::TurnDiffTracker;
238238
use crate::unified_exec::UnifiedExecProcessManager;
239239
use crate::util::backoff;
240240
use crate::windows_sandbox::WindowsSandboxLevelExt;
241+
use crate::zsh_exec_bridge::ZshExecBridge;
241242
use codex_async_utils::OrCancelExt;
242243
use codex_otel::OtelManager;
243244
use codex_otel::TelemetryAuthMode;
@@ -1190,7 +1191,22 @@ impl Session {
11901191
config.active_profile.clone(),
11911192
);
11921193

1193-
let mut default_shell = shell::default_user_shell();
1194+
let use_zsh_fork_shell = config.features.enabled(Feature::ShellZshFork);
1195+
let mut default_shell = if use_zsh_fork_shell {
1196+
let zsh_path = config.zsh_path.as_ref().ok_or_else(|| {
1197+
anyhow::anyhow!(
1198+
"zsh fork feature enabled, but `zsh_path` is not configured; set `zsh_path` in config.toml"
1199+
)
1200+
})?;
1201+
shell::get_shell(shell::ShellType::Zsh, Some(zsh_path)).ok_or_else(|| {
1202+
anyhow::anyhow!(
1203+
"zsh fork feature enabled, but zsh_path `{}` is not usable; set `zsh_path` to a valid zsh executable",
1204+
zsh_path.display()
1205+
)
1206+
})?
1207+
} else {
1208+
shell::default_user_shell()
1209+
};
11941210
// Create the mutable state for the Session.
11951211
let shell_snapshot_tx = if config.features.enabled(Feature::ShellSnapshot) {
11961212
ShellSnapshot::start_snapshotting(
@@ -1261,10 +1277,17 @@ impl Session {
12611277
(None, None)
12621278
};
12631279

1280+
let zsh_exec_bridge =
1281+
ZshExecBridge::new(config.zsh_path.clone(), config.codex_home.clone());
1282+
zsh_exec_bridge
1283+
.initialize_for_session(&conversation_id.to_string())
1284+
.await;
1285+
12641286
let services = SessionServices {
12651287
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())),
12661288
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
12671289
unified_exec_manager: UnifiedExecProcessManager::default(),
1290+
zsh_exec_bridge,
12681291
analytics_events_client: AnalyticsEventsClient::new(
12691292
Arc::clone(&config),
12701293
Arc::clone(&auth_manager),
@@ -4049,6 +4072,7 @@ mod handlers {
40494072
.unified_exec_manager
40504073
.terminate_all_processes()
40514074
.await;
4075+
sess.services.zsh_exec_bridge.shutdown().await;
40524076
info!("Shutting down Codex instance");
40534077
let history = sess.clone_history().await;
40544078
let turn_count = history
@@ -7281,6 +7305,81 @@ mod tests {
72817305
}
72827306
}
72837307

7308+
#[tokio::test]
7309+
async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
7310+
let codex_home = tempfile::tempdir().expect("create temp dir");
7311+
let mut config = build_test_config(codex_home.path()).await;
7312+
config.features.enable(Feature::ShellZshFork);
7313+
config.zsh_path = None;
7314+
let config = Arc::new(config);
7315+
7316+
let auth_manager =
7317+
AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
7318+
let models_manager = Arc::new(ModelsManager::new(
7319+
config.codex_home.clone(),
7320+
auth_manager.clone(),
7321+
));
7322+
let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref());
7323+
let model_info =
7324+
ModelsManager::construct_model_info_offline_for_tests(model.as_str(), &config);
7325+
let collaboration_mode = CollaborationMode {
7326+
mode: ModeKind::Default,
7327+
settings: Settings {
7328+
model,
7329+
reasoning_effort: config.model_reasoning_effort,
7330+
developer_instructions: None,
7331+
},
7332+
};
7333+
let session_configuration = SessionConfiguration {
7334+
provider: config.model_provider.clone(),
7335+
collaboration_mode,
7336+
model_reasoning_summary: config.model_reasoning_summary,
7337+
developer_instructions: config.developer_instructions.clone(),
7338+
user_instructions: config.user_instructions.clone(),
7339+
personality: config.personality,
7340+
base_instructions: config
7341+
.base_instructions
7342+
.clone()
7343+
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
7344+
compact_prompt: config.compact_prompt.clone(),
7345+
approval_policy: config.permissions.approval_policy.clone(),
7346+
sandbox_policy: config.permissions.sandbox_policy.clone(),
7347+
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
7348+
cwd: config.cwd.clone(),
7349+
codex_home: config.codex_home.clone(),
7350+
thread_name: None,
7351+
original_config_do_not_use: Arc::clone(&config),
7352+
session_source: SessionSource::Exec,
7353+
dynamic_tools: Vec::new(),
7354+
persist_extended_history: false,
7355+
};
7356+
7357+
let (tx_event, _rx_event) = async_channel::unbounded();
7358+
let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit);
7359+
let result = Session::new(
7360+
session_configuration,
7361+
Arc::clone(&config),
7362+
auth_manager,
7363+
models_manager,
7364+
ExecPolicyManager::default(),
7365+
tx_event,
7366+
agent_status_tx,
7367+
InitialHistory::New,
7368+
SessionSource::Exec,
7369+
Arc::new(SkillsManager::new(config.codex_home.clone())),
7370+
Arc::new(FileWatcher::noop()),
7371+
AgentControl::default(),
7372+
)
7373+
.await;
7374+
7375+
let err = match result {
7376+
Ok(_) => panic!("expected startup to fail"),
7377+
Err(err) => err,
7378+
};
7379+
let msg = format!("{err:#}");
7380+
assert!(msg.contains("zsh fork feature enabled, but `zsh_path` is not configured"));
7381+
}
7382+
72847383
// todo: use online model info
72857384
pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
72867385
let (tx_event, _rx_event) = async_channel::unbounded();
@@ -7354,6 +7453,7 @@ mod tests {
73547453
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())),
73557454
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
73567455
unified_exec_manager: UnifiedExecProcessManager::default(),
7456+
zsh_exec_bridge: ZshExecBridge::default(),
73577457
analytics_events_client: AnalyticsEventsClient::new(
73587458
Arc::clone(&config),
73597459
Arc::clone(&auth_manager),
@@ -7502,6 +7602,7 @@ mod tests {
75027602
mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())),
75037603
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
75047604
unified_exec_manager: UnifiedExecProcessManager::default(),
7605+
zsh_exec_bridge: ZshExecBridge::default(),
75057606
analytics_events_client: AnalyticsEventsClient::new(
75067607
Arc::clone(&config),
75077608
Arc::clone(&auth_manager),

codex-rs/core/src/config/mod.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,8 @@ pub struct Config {
328328

329329
/// Optional absolute path to the Node runtime used by `js_repl`.
330330
pub js_repl_node_path: Option<PathBuf>,
331+
/// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution.
332+
pub zsh_path: Option<PathBuf>,
331333

332334
/// Value to use for `reasoning.effort` when making a request using the
333335
/// Responses API.
@@ -962,6 +964,8 @@ pub struct ConfigToml {
962964

963965
/// Optional absolute path to the Node runtime used by `js_repl`.
964966
pub js_repl_node_path: Option<AbsolutePathBuf>,
967+
/// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution.
968+
pub zsh_path: Option<AbsolutePathBuf>,
965969

966970
/// Profile to use from the `profiles` map.
967971
pub profile: Option<String>,
@@ -1311,6 +1315,7 @@ pub struct ConfigOverrides {
13111315
pub config_profile: Option<String>,
13121316
pub codex_linux_sandbox_exe: Option<PathBuf>,
13131317
pub js_repl_node_path: Option<PathBuf>,
1318+
pub zsh_path: Option<PathBuf>,
13141319
pub base_instructions: Option<String>,
13151320
pub developer_instructions: Option<String>,
13161321
pub personality: Option<Personality>,
@@ -1438,6 +1443,7 @@ impl Config {
14381443
config_profile: config_profile_key,
14391444
codex_linux_sandbox_exe,
14401445
js_repl_node_path: js_repl_node_path_override,
1446+
zsh_path: zsh_path_override,
14411447
base_instructions,
14421448
developer_instructions,
14431449
personality,
@@ -1674,6 +1680,9 @@ impl Config {
16741680
let js_repl_node_path = js_repl_node_path_override
16751681
.or(config_profile.js_repl_node_path.map(Into::into))
16761682
.or(cfg.js_repl_node_path.map(Into::into));
1683+
let zsh_path = zsh_path_override
1684+
.or(config_profile.zsh_path.map(Into::into))
1685+
.or(cfg.zsh_path.map(Into::into));
16771686

16781687
let review_model = override_review_model.or(cfg.review_model);
16791688

@@ -1796,6 +1805,7 @@ impl Config {
17961805
file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode),
17971806
codex_linux_sandbox_exe,
17981807
js_repl_node_path,
1808+
zsh_path,
17991809

18001810
hide_agent_reasoning: cfg.hide_agent_reasoning.unwrap_or(false),
18011811
show_raw_agent_reasoning: cfg
@@ -4125,6 +4135,7 @@ model_verbosity = "high"
41254135
file_opener: UriBasedFileOpener::VsCode,
41264136
codex_linux_sandbox_exe: None,
41274137
js_repl_node_path: None,
4138+
zsh_path: None,
41284139
hide_agent_reasoning: false,
41294140
show_raw_agent_reasoning: false,
41304141
model_reasoning_effort: Some(ReasoningEffort::High),
@@ -4236,6 +4247,7 @@ model_verbosity = "high"
42364247
file_opener: UriBasedFileOpener::VsCode,
42374248
codex_linux_sandbox_exe: None,
42384249
js_repl_node_path: None,
4250+
zsh_path: None,
42394251
hide_agent_reasoning: false,
42404252
show_raw_agent_reasoning: false,
42414253
model_reasoning_effort: None,
@@ -4345,6 +4357,7 @@ model_verbosity = "high"
43454357
file_opener: UriBasedFileOpener::VsCode,
43464358
codex_linux_sandbox_exe: None,
43474359
js_repl_node_path: None,
4360+
zsh_path: None,
43484361
hide_agent_reasoning: false,
43494362
show_raw_agent_reasoning: false,
43504363
model_reasoning_effort: None,
@@ -4440,6 +4453,7 @@ model_verbosity = "high"
44404453
file_opener: UriBasedFileOpener::VsCode,
44414454
codex_linux_sandbox_exe: None,
44424455
js_repl_node_path: None,
4456+
zsh_path: None,
44434457
hide_agent_reasoning: false,
44444458
show_raw_agent_reasoning: false,
44454459
model_reasoning_effort: Some(ReasoningEffort::High),

codex-rs/core/src/config/profile.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ pub struct ConfigProfile {
3030
pub chatgpt_base_url: Option<String>,
3131
/// Optional path to a file containing model instructions.
3232
pub model_instructions_file: Option<AbsolutePathBuf>,
33+
/// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution.
34+
pub zsh_path: Option<AbsolutePathBuf>,
3335
pub js_repl_node_path: Option<AbsolutePathBuf>,
3436
/// Deprecated: ignored. Use `model_instructions_file`.
3537
#[schemars(skip)]

codex-rs/core/src/features.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ pub enum Feature {
8484
JsReplToolsOnly,
8585
/// Use the single unified PTY-backed exec tool.
8686
UnifiedExec,
87+
/// Route shell tool execution through the zsh exec bridge.
88+
ShellZshFork,
8789
/// Include the freeform apply_patch tool.
8890
ApplyPatchFreeform,
8991
/// Allow the model to request web searches that fetch live content.
@@ -428,6 +430,12 @@ pub const FEATURES: &[FeatureSpec] = &[
428430
stage: Stage::Stable,
429431
default_enabled: !cfg!(windows),
430432
},
433+
FeatureSpec {
434+
id: Feature::ShellZshFork,
435+
key: "shell_zsh_fork",
436+
stage: Stage::UnderDevelopment,
437+
default_enabled: false,
438+
},
431439
FeatureSpec {
432440
id: Feature::ShellSnapshot,
433441
key: "shell_snapshot",

codex-rs/core/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ pub mod terminal;
108108
mod tools;
109109
pub mod turn_diff_tracker;
110110
mod turn_metadata;
111+
mod zsh_exec_bridge;
111112
pub use rollout::ARCHIVED_SESSIONS_SUBDIR;
112113
pub use rollout::INTERACTIVE_SESSION_SOURCES;
113114
pub use rollout::RolloutRecorder;
@@ -151,6 +152,7 @@ pub use file_watcher::FileWatcherEvent;
151152
pub use safety::get_platform_sandbox;
152153
pub use tools::spec::parse_tool_input_schema;
153154
pub use turn_metadata::build_turn_metadata_header;
155+
pub use zsh_exec_bridge::maybe_run_zsh_exec_wrapper_mode;
154156
// Re-export the protocol types from the standalone `codex-protocol` crate so existing
155157
// `codex_core::protocol::...` references continue to work across the workspace.
156158
pub use codex_protocol::protocol;

codex-rs/core/src/sandboxing/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,12 +164,19 @@ impl SandboxManager {
164164
SandboxType::MacosSeatbelt => {
165165
let mut seatbelt_env = HashMap::new();
166166
seatbelt_env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
167+
let zsh_exec_bridge_wrapper_socket = env
168+
.get(crate::zsh_exec_bridge::ZSH_EXEC_BRIDGE_WRAPPER_SOCKET_ENV_VAR)
169+
.map(PathBuf::from);
170+
let zsh_exec_bridge_allowed_unix_sockets = zsh_exec_bridge_wrapper_socket
171+
.as_ref()
172+
.map_or_else(Vec::new, |path| vec![path.clone()]);
167173
let mut args = create_seatbelt_command_args(
168174
command.clone(),
169175
policy,
170176
sandbox_policy_cwd,
171177
enforce_managed_network,
172178
network,
179+
&zsh_exec_bridge_allowed_unix_sockets,
173180
);
174181
let mut full_command = Vec::with_capacity(1 + args.len());
175182
full_command.push(MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string());

0 commit comments

Comments
 (0)