diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 2450cdbb59..32f2c83d16 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1020,6 +1020,9 @@ "unified_exec": { "type": "boolean" }, + "unified_exec_tty": { + "type": "boolean" + }, "unified_exec_zsh_fork": { "type": "boolean" }, @@ -6378,6 +6381,9 @@ "unified_exec": { "type": "boolean" }, + "unified_exec_tty": { + "type": "boolean" + }, "unified_exec_zsh_fork": { "type": "boolean" }, diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index 8b97ff06b2..a8d0fb9282 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -59,6 +59,7 @@ const EXEC_COMMAND_REJECTION_MAX_BYTES: usize = 900; #[derive(Clone, Copy)] pub(crate) struct ExecCommandHandlerOptions { pub(crate) allow_login_shell: bool, + pub(crate) allow_tty: bool, pub(crate) exec_permission_approvals_enabled: bool, pub(crate) include_environment_id: bool, pub(crate) include_shell_parameter: bool, @@ -82,6 +83,7 @@ impl Default for ExecCommandHandler { lifetime: ExecCommandLifetime::Interactive, options: ExecCommandHandlerOptions { allow_login_shell: false, + allow_tty: true, exec_permission_approvals_enabled: false, include_environment_id: false, include_shell_parameter: true, @@ -122,10 +124,19 @@ impl ToolExecutor for ExecCommandHandler { self.options.include_shell_parameter, self.options.include_windows_shell_guidance, ); - match self.lifetime { + let mut spec = match self.lifetime { ExecCommandLifetime::Interactive => spec, ExecCommandLifetime::OneShot => one_shot_exec_command_spec(spec), + }; + if !self.options.allow_tty + && let ToolSpec::Function(spec) = &mut spec + { + spec.parameters + .properties + .get_or_insert_default() + .remove("tty"); } + spec } fn supports_parallel_tool_calls(&self) -> bool { @@ -230,6 +241,11 @@ impl ExecCommandHandler { parse_arguments(&arguments)? } }; + if args.tty && !session.features().enabled(Feature::UnifiedExecTty) { + return Err(FunctionCallError::RespondToModel( + "TTY execution is disabled by config; omit `tty` or set it to false.".to_string(), + )); + } let sandbox_permissions = resolve_sandbox_permissions(args.sandbox_permissions, args.justification.as_deref())?; let hook_command = args.cmd.clone(); diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index ef31ab2034..01054b1b50 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -997,6 +997,10 @@ fn add_core_tool_sources(context: &CoreToolPlanContext<'_>, registry: &mut ToolR { registry.add(ExecCommandHandler::new(ExecCommandHandlerOptions { allow_login_shell: any_environment_allows_login_shell(context.environments), + allow_tty: turn_context + .config + .features + .enabled(Feature::UnifiedExecTty), exec_permission_approvals_enabled: false, include_environment_id, include_shell_parameter: unified_exec_should_include_shell_parameter( @@ -1088,6 +1092,7 @@ fn add_shell_tools(context: &CoreToolPlanContext<'_>, registry: &mut ToolRegistr let include_environment_id = matches!(environment_mode, ToolEnvironmentMode::Multiple); let options = ExecCommandHandlerOptions { allow_login_shell, + allow_tty: features.enabled(Feature::UnifiedExecTty), exec_permission_approvals_enabled, include_environment_id, include_shell_parameter: unified_exec_should_include_shell_parameter( diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__unified_exec__exec_command_tty_disabled.snap b/codex-rs/core/tests/suite/snapshots/all__suite__unified_exec__exec_command_tty_disabled.snap new file mode 100644 index 0000000000..8d261cccdf --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__unified_exec__exec_command_tty_disabled.snap @@ -0,0 +1,5 @@ +--- +source: core/tests/suite/unified_exec.rs +expression: rejection +--- +TTY execution is disabled by config; omit `tty` or set it to false. diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 1c9142dd3f..ad3f248717 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -306,6 +306,96 @@ async fn exec_command_hides_and_rejects_login_when_disabled() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_command_hides_and_rejects_tty_when_disabled() -> Result<()> { + skip_if_no_network!(Ok(())); + + let builder = test_codex().with_model("gpt-5.4").with_cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + "[features]\nunified_exec_tty = false\n", + ), + ); + let harness = TestCodexHarness::with_auto_env_builder(builder).await?; + let call_id = "tty-denied"; + let arguments = json!({"cmd": "echo should-not-run > tty-disabled-rejected", "tty": true}); + let request_log = mount_sse_sequence( + harness.server(), + vec![ + sse(vec![ + ev_response_created(call_id), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&arguments)?), + ev_completed(call_id), + ]), + sse(vec![ev_completed("done")]), + ], + ) + .await; + + harness + .submit("run commands with and without a terminal") + .await?; + + let rejection = harness.function_call_stdout("tty-denied").await; + insta::assert_snapshot!("exec_command_tty_disabled", rejection); + assert!(!harness.path_exists("tty-disabled-rejected").await?); + + let request = request_log.requests()[0].body_json(); + let tools = request["tools"] + .as_array() + .expect("tools should be an array"); + let exec_tool = tools + .iter() + .find(|tool| tool["name"] == "exec_command") + .expect("exec_command should be available"); + assert!(exec_tool["parameters"]["properties"].get("tty").is_none()); + assert!(tools.iter().any(|tool| tool["name"] == "write_stdin")); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_command_runs_without_tty_when_tty_disabled() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_wine_exec!( + Ok(()), + "basic PowerShell execution through Wine is unavailable" + ); + + let builder = test_codex().with_model("gpt-5.4").with_cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + "[features]\nunified_exec_tty = false\n", + ), + ); + let harness = TestCodexHarness::with_auto_env_builder(builder).await?; + let mut responses = Vec::new(); + for (call_id, arguments) in [ + ("tty-false", json!({"cmd": "echo pipe-ok", "tty": false})), + ("tty-omitted", json!({"cmd": "echo pipe-ok"})), + ] { + responses.push(sse(vec![ + ev_response_created(call_id), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&arguments)?), + ev_completed(call_id), + ])); + } + responses.push(sse(vec![ev_completed("done")])); + mount_sse_sequence(harness.server(), responses).await; + + harness + .submit("run commands with and without a terminal") + .await?; + + for call_id in ["tty-false", "tty-omitted"] { + let output = parse_unified_exec_output(&harness.function_call_stdout(call_id).await)?; + assert_eq!( + (output.exit_code, output.output.trim()), + (Some(0), "pipe-ok") + ); + } + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn exec_command_does_not_expose_configured_noise_auth_token() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index f11cc6eb3f..660e415238 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -124,6 +124,8 @@ pub enum Feature { CodeModeOnly, /// Use the single unified PTY-backed exec tool. UnifiedExec, + /// Allow unified exec commands to allocate an interactive terminal. + UnifiedExecTty, /// Route shell tool execution through the zsh exec bridge. ShellZshFork, /// Allow unified exec to compose with the zsh exec bridge. @@ -932,6 +934,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::UnifiedExecTty, + key: "unified_exec_tty", + stage: Stage::Stable, + default_enabled: true, + }, FeatureSpec { id: Feature::ShellZshFork, key: "shell_zsh_fork",