Skip to content

Commit d6748f7

Browse files
authored
feat: gate unified exec zsh fork composition (#24979)
## Why `shell_zsh_fork` and unified exec need to remain independently controllable for enterprise rollouts, but we also need a third mode that composes them. That composed mode is intended to preserve unified exec command lifecycle support while letting the zsh fork provide more accurate `execv(2)` interception. Enabling `unified_exec_zsh_fork` by itself is intentionally not sufficient. It is a composition gate, not a dependency-enabling shortcut: - `unified_exec` selects the PTY-backed unified exec tool. - `shell_zsh_fork` opts into the zsh fork backend. - `unified_exec_zsh_fork` only allows those two already-enabled modes to be composed so local zsh unified exec commands can launch through the zsh fork. This separation is deliberate. Enterprises and staged rollouts must be able to enable or disable unified exec and zsh-fork independently. If `unified_exec_zsh_fork` implied either dependency, then enabling one under-development composition flag would silently activate a shell backend that the configured feature set left disabled. This PR introduces only the configuration and planning gate for that composition. Existing `shell_zsh_fork` behavior continues to use the standalone shell tool unless the new composition feature is explicitly enabled alongside both dependencies. ## What Changed - Added the under-development feature flag `unified_exec_zsh_fork`. - Added `UnifiedExecFeatureMode` so the three input feature flags collapse into `Disabled`, `Direct`, or `ZshFork` mode before tool planning. - Updated tool selection so zsh-fork composition requires `unified_exec`, `shell_zsh_fork`, and `unified_exec_zsh_fork`. - Kept the existing standalone zsh-fork shell tool behavior when only `shell_zsh_fork` is enabled. - Updated config schema output for the new feature flag. ## Verification - Added feature and tool-config coverage for the new gate. - Added planner coverage proving `shell_zsh_fork` remains standalone until composition is explicitly enabled. - Ran focused tests for `codex-features`, `codex-tools`, and the affected `codex-core` planner case. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/24979). * #24982 * #24981 * #24980 * __->__ #24979
1 parent 009e6c4 commit d6748f7

9 files changed

Lines changed: 168 additions & 21 deletions

File tree

codex-rs/core/config.schema.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,9 @@
617617
"unified_exec": {
618618
"type": "boolean"
619619
},
620+
"unified_exec_zsh_fork": {
621+
"type": "boolean"
622+
},
620623
"use_legacy_landlock": {
621624
"type": "boolean"
622625
},
@@ -4737,6 +4740,9 @@
47374740
"unified_exec": {
47384741
"type": "boolean"
47394742
},
4743+
"unified_exec_zsh_fork": {
4744+
"type": "boolean"
4745+
},
47404746
"use_legacy_landlock": {
47414747
"type": "boolean"
47424748
},

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,6 @@ use codex_protocol::protocol::WarningEvent;
362362
use codex_protocol::user_input::UserInput;
363363
use codex_tools::ToolEnvironmentMode;
364364
use codex_tools::UnifiedExecShellMode;
365-
use codex_tools::shell_command_backend_for_features;
366365
use codex_utils_absolute_path::AbsolutePathBuf;
367366
#[cfg(test)]
368367
use codex_utils_stream_parser::ProposedPlanSegment;

codex-rs/core/src/session/review.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,8 @@ pub(super) async fn spawn_review_thread(
3131
.models_manager
3232
.list_models(RefreshStrategy::OnlineIfUncached)
3333
.await;
34-
let shell_command_backend = shell_command_backend_for_features(review_features.get());
3534
let unified_exec_shell_mode = UnifiedExecShellMode::for_session(
36-
shell_command_backend,
35+
codex_tools::unified_exec_feature_mode_for_features(review_features.get()),
3736
crate::tools::tool_user_shell_type(sess.services.user_shell.as_ref()),
3837
sess.services.shell_zsh_path.as_ref(),
3938
sess.services.main_execve_wrapper_exe.as_ref(),

codex-rs/core/src/session/turn_context.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -480,10 +480,8 @@ impl Session {
480480
let provider_for_context = create_model_provider(provider, auth_manager);
481481
let session_telemetry_for_context = session_telemetry;
482482
let available_models = models_manager.try_list_models().unwrap_or_default();
483-
let shell_command_backend =
484-
shell_command_backend_for_features(per_turn_config.features.get());
485483
let unified_exec_shell_mode = UnifiedExecShellMode::for_session(
486-
shell_command_backend,
484+
codex_tools::unified_exec_feature_mode_for_features(per_turn_config.features.get()),
487485
crate::tools::tool_user_shell_type(user_shell),
488486
shell_zsh_path,
489487
main_execve_wrapper_exe,

codex-rs/core/src/tools/spec_plan_tests.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,46 @@ async fn shell_family_registers_visible_unified_exec_and_hidden_legacy_shell() {
408408
assert_eq!(plan.exposure("shell_command"), ToolExposure::Hidden);
409409
}
410410

411+
#[tokio::test]
412+
async fn shell_zsh_fork_stays_standalone_until_unified_exec_composition_is_enabled() {
413+
let standalone = probe(|turn| {
414+
set_features(turn, &[Feature::ShellTool, Feature::UnifiedExec]);
415+
set_feature(turn, Feature::ShellZshFork, /*enabled*/ true);
416+
set_feature(turn, Feature::UnifiedExecZshFork, /*enabled*/ false);
417+
turn.model_info.shell_type = ConfigShellToolType::ShellCommand;
418+
})
419+
.await;
420+
421+
standalone.assert_visible_contains(&["shell_command"]);
422+
standalone.assert_visible_lacks(&["exec_command", "write_stdin"]);
423+
standalone.assert_registered_contains(&["shell_command"]);
424+
standalone.assert_registered_lacks(&["exec_command", "write_stdin"]);
425+
426+
let composed = probe(|turn| {
427+
set_features(
428+
turn,
429+
&[
430+
Feature::ShellTool,
431+
Feature::UnifiedExec,
432+
Feature::ShellZshFork,
433+
Feature::UnifiedExecZshFork,
434+
],
435+
);
436+
turn.model_info.shell_type = ConfigShellToolType::ShellCommand;
437+
})
438+
.await;
439+
440+
if codex_utils_pty::conpty_supported() {
441+
composed.assert_visible_contains(&["exec_command", "write_stdin"]);
442+
composed.assert_visible_lacks(&["shell_command"]);
443+
composed.assert_registered_contains(&["exec_command", "write_stdin", "shell_command"]);
444+
assert_eq!(composed.exposure("shell_command"), ToolExposure::Hidden);
445+
} else {
446+
composed.assert_visible_contains(&["shell_command"]);
447+
composed.assert_visible_lacks(&["exec_command", "write_stdin"]);
448+
}
449+
}
450+
411451
#[tokio::test]
412452
async fn environment_count_controls_environment_backed_tools() {
413453
let no_environment = probe(|turn| {

codex-rs/features/src/lib.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,12 @@ pub enum Feature {
9090
UnifiedExec,
9191
/// Route shell tool execution through the zsh exec bridge.
9292
ShellZshFork,
93+
/// Allow unified exec to compose with the zsh exec bridge.
94+
///
95+
/// This flag is only a composition gate. Enabling it by itself must not turn
96+
/// on either `unified_exec` or `shell_zsh_fork` because those features have
97+
/// separate rollout and enterprise controls.
98+
UnifiedExecZshFork,
9399
/// Reflow transcript scrollback when the terminal is resized.
94100
TerminalResizeReflow,
95101
/// Stream structured progress while apply_patch input is being generated.
@@ -741,6 +747,12 @@ pub const FEATURES: &[FeatureSpec] = &[
741747
stage: Stage::UnderDevelopment,
742748
default_enabled: false,
743749
},
750+
FeatureSpec {
751+
id: Feature::UnifiedExecZshFork,
752+
key: "unified_exec_zsh_fork",
753+
stage: Stage::UnderDevelopment,
754+
default_enabled: false,
755+
},
744756
FeatureSpec {
745757
id: Feature::ShellSnapshot,
746758
key: "shell_snapshot",

codex-rs/tools/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,13 @@ pub use tool_call::TurnItemEmitter;
7171
pub use tool_config::ShellCommandBackendConfig;
7272
pub use tool_config::ToolEnvironmentMode;
7373
pub use tool_config::ToolUserShellType;
74+
pub use tool_config::UnifiedExecFeatureMode;
7475
pub use tool_config::UnifiedExecShellMode;
7576
pub use tool_config::ZshForkConfig;
7677
pub use tool_config::request_user_input_available_modes;
7778
pub use tool_config::shell_command_backend_for_features;
7879
pub use tool_config::shell_type_for_model_and_features;
80+
pub use tool_config::unified_exec_feature_mode_for_features;
7981
pub use tool_definition::ToolDefinition;
8082
pub use tool_discovery::DiscoverablePluginInfo;
8183
pub use tool_discovery::DiscoverableTool;

codex-rs/tools/src/tool_config.rs

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,19 @@ pub enum ShellCommandBackendConfig {
1313
ZshFork,
1414
}
1515

16+
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
17+
pub enum UnifiedExecFeatureMode {
18+
/// Unified exec should not be selected by this feature set.
19+
///
20+
/// This includes standalone `shell_zsh_fork`: until
21+
/// `unified_exec_zsh_fork` is enabled too, `shell_zsh_fork` keeps using
22+
/// the shell command backend instead of silently opting unified exec into
23+
/// zsh-fork interception.
24+
Disabled,
25+
Direct,
26+
ZshFork,
27+
}
28+
1629
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
1730
pub enum ToolUserShellType {
1831
Zsh,
@@ -41,33 +54,64 @@ pub fn shell_command_backend_for_features(features: &Features) -> ShellCommandBa
4154
}
4255
}
4356

57+
/// Returns the unified-exec mode requested by feature policy, before runtime
58+
/// session inputs such as platform, user shell, and zsh-fork binary paths are
59+
/// resolved.
60+
///
61+
/// `unified_exec_zsh_fork` is only a composition gate. It does not enable
62+
/// either underlying shell mode on its own, so disabling `unified_exec` or
63+
/// `shell_zsh_fork` keeps those features independently off. This lets
64+
/// enterprise deployments opt into, or out of, unified exec and zsh-fork
65+
/// behavior separately; otherwise enabling the composition flag would silently
66+
/// activate a shell backend that the configured feature set left disabled.
67+
pub fn unified_exec_feature_mode_for_features(features: &Features) -> UnifiedExecFeatureMode {
68+
if !features.enabled(Feature::ShellTool) || !features.enabled(Feature::UnifiedExec) {
69+
UnifiedExecFeatureMode::Disabled
70+
} else if features.enabled(Feature::ShellZshFork) {
71+
if features.enabled(Feature::UnifiedExecZshFork) {
72+
UnifiedExecFeatureMode::ZshFork
73+
} else {
74+
UnifiedExecFeatureMode::Disabled
75+
}
76+
} else {
77+
UnifiedExecFeatureMode::Direct
78+
}
79+
}
80+
4481
pub fn shell_type_for_model_and_features(
4582
model_info: &ModelInfo,
4683
features: &Features,
4784
) -> ConfigShellToolType {
48-
let unified_exec_enabled = features.enabled(Feature::UnifiedExec);
85+
let unified_exec_feature_mode = unified_exec_feature_mode_for_features(features);
86+
let unified_exec_disabled =
87+
matches!(unified_exec_feature_mode, UnifiedExecFeatureMode::Disabled);
4988
let model_shell_type = match model_info.shell_type {
50-
ConfigShellToolType::UnifiedExec if !unified_exec_enabled => {
89+
ConfigShellToolType::UnifiedExec if unified_exec_disabled => {
5190
ConfigShellToolType::ShellCommand
5291
}
5392
ConfigShellToolType::Default | ConfigShellToolType::Local => {
5493
ConfigShellToolType::ShellCommand
5594
}
5695
other => other,
5796
};
97+
let shell_command_type = match shell_command_backend_for_features(features) {
98+
ShellCommandBackendConfig::Classic => model_shell_type,
99+
ShellCommandBackendConfig::ZshFork => ConfigShellToolType::ShellCommand,
100+
};
58101

59102
if !features.enabled(Feature::ShellTool) {
60103
ConfigShellToolType::Disabled
61-
} else if features.enabled(Feature::ShellZshFork) {
62-
ConfigShellToolType::ShellCommand
63-
} else if unified_exec_enabled {
64-
if codex_utils_pty::conpty_supported() {
65-
ConfigShellToolType::UnifiedExec
66-
} else {
67-
ConfigShellToolType::ShellCommand
68-
}
69104
} else {
70-
model_shell_type
105+
match unified_exec_feature_mode {
106+
UnifiedExecFeatureMode::Disabled => shell_command_type,
107+
UnifiedExecFeatureMode::Direct | UnifiedExecFeatureMode::ZshFork => {
108+
if codex_utils_pty::conpty_supported() {
109+
ConfigShellToolType::UnifiedExec
110+
} else {
111+
ConfigShellToolType::ShellCommand
112+
}
113+
}
114+
}
71115
}
72116
}
73117

@@ -85,13 +129,13 @@ pub struct ZshForkConfig {
85129

86130
impl UnifiedExecShellMode {
87131
pub fn for_session(
88-
shell_command_backend: ShellCommandBackendConfig,
132+
feature_mode: UnifiedExecFeatureMode,
89133
user_shell_type: ToolUserShellType,
90134
shell_zsh_path: Option<&PathBuf>,
91135
main_execve_wrapper_exe: Option<&PathBuf>,
92136
) -> Self {
93137
if cfg!(unix)
94-
&& shell_command_backend == ShellCommandBackendConfig::ZshFork
138+
&& matches!(feature_mode, UnifiedExecFeatureMode::ZshFork)
95139
&& matches!(user_shell_type, ToolUserShellType::Zsh)
96140
&& let (Some(shell_zsh_path), Some(main_execve_wrapper_exe)) =
97141
(shell_zsh_path, main_execve_wrapper_exe)

codex-rs/tools/src/tool_config_tests.rs

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ fn shell_features() -> Features {
5454
features.enable(Feature::ShellTool);
5555
features.disable(Feature::ShellZshFork);
5656
features.disable(Feature::UnifiedExec);
57+
features.disable(Feature::UnifiedExecZshFork);
5758
features
5859
}
5960

@@ -83,6 +84,12 @@ fn shell_type_is_derived_from_model_and_feature_gates() {
8384
ConfigShellToolType::ShellCommand
8485
);
8586

87+
features.enable(Feature::UnifiedExecZshFork);
88+
assert_eq!(
89+
shell_type_for_model_and_features(&model, &features),
90+
expected_unified_exec
91+
);
92+
8693
features.disable(Feature::ShellTool);
8794
assert_eq!(
8895
shell_type_for_model_and_features(&model, &features),
@@ -111,6 +118,46 @@ fn shell_command_backend_requires_both_shell_tool_and_zsh_fork() {
111118
);
112119
}
113120

121+
#[test]
122+
fn unified_exec_feature_mode_follows_composition_dependencies() {
123+
let mut features = shell_features();
124+
assert_eq!(
125+
unified_exec_feature_mode_for_features(&features),
126+
UnifiedExecFeatureMode::Disabled
127+
);
128+
129+
features.enable(Feature::UnifiedExec);
130+
assert_eq!(
131+
unified_exec_feature_mode_for_features(&features),
132+
UnifiedExecFeatureMode::Direct
133+
);
134+
135+
features.enable(Feature::UnifiedExecZshFork);
136+
assert_eq!(
137+
unified_exec_feature_mode_for_features(&features),
138+
UnifiedExecFeatureMode::Direct
139+
);
140+
141+
features.enable(Feature::ShellZshFork);
142+
features.disable(Feature::UnifiedExecZshFork);
143+
assert_eq!(
144+
unified_exec_feature_mode_for_features(&features),
145+
UnifiedExecFeatureMode::Disabled
146+
);
147+
148+
features.enable(Feature::UnifiedExecZshFork);
149+
assert_eq!(
150+
unified_exec_feature_mode_for_features(&features),
151+
UnifiedExecFeatureMode::ZshFork
152+
);
153+
154+
features.disable(Feature::ShellTool);
155+
assert_eq!(
156+
unified_exec_feature_mode_for_features(&features),
157+
UnifiedExecFeatureMode::Disabled
158+
);
159+
}
160+
114161
#[test]
115162
fn request_user_input_modes_follow_default_mode_feature() {
116163
let mut features = Features::with_defaults();
@@ -133,7 +180,7 @@ fn unified_exec_shell_mode_uses_zsh_fork_only_when_all_inputs_match() {
133180
let shell = exe.clone();
134181

135182
let mode = UnifiedExecShellMode::for_session(
136-
ShellCommandBackendConfig::ZshFork,
183+
UnifiedExecFeatureMode::ZshFork,
137184
ToolUserShellType::Zsh,
138185
Some(&shell),
139186
Some(&exe),
@@ -146,7 +193,7 @@ fn unified_exec_shell_mode_uses_zsh_fork_only_when_all_inputs_match() {
146193

147194
assert_eq!(
148195
UnifiedExecShellMode::for_session(
149-
ShellCommandBackendConfig::Classic,
196+
UnifiedExecFeatureMode::Direct,
150197
ToolUserShellType::Zsh,
151198
Some(&shell),
152199
Some(&exe),

0 commit comments

Comments
 (0)