Standardize shell execution on unified exec (#39772)

## What changed

- Use `exec_command` and `write_stdin` as the shell tool surface.
- Treat legacy `default`, `local`, and `shell_command` model metadata as `unified_exec`.
- Remove obsolete shell-selection configuration and runtime paths while preserving the feature and policy gates for zsh fork execution.

GitOrigin-RevId: d743cbe598630d73052f1fecad680c4cde17977d
This commit is contained in:
jif
2026-08-20 18:29:35 +00:00
committed by copyberry
parent 5bcd7b0fbc
commit bce5f2fcfc
40 changed files with 189 additions and 992 deletions
+6 -183
View File
@@ -112,7 +112,6 @@ impl ToolCallRuntime {
let router = &self.step_context.tool_router;
let supports_parallel = router.tool_supports_parallel(&call);
let tool_runtime = router.tool_runtime(&call);
let wait_for_runtime_cancellation = router.tool_waits_for_runtime_cancellation(&call);
let router = Arc::clone(router);
let session = Arc::clone(&self.session);
let step_context = Arc::clone(&self.step_context);
@@ -185,24 +184,11 @@ impl ToolCallRuntime {
} else {
let secs = started.elapsed().as_secs_f32().max(0.1);
abort_dispatch_span.record("aborted", true);
if wait_for_runtime_cancellation {
if terminal_outcome_reached.swap(true, Ordering::AcqRel) {
return dispatch_handle.await.map_err(Self::tool_task_join_error)?;
}
// The abort owns the terminal outcome; await only so
// the runtime can finish process teardown.
match dispatch_handle.await {
Ok(_) => {}
Err(err) if err.is_cancelled() => {}
Err(err) => return Err(Self::tool_task_join_error(err)),
}
} else {
dispatch_handle.abort();
match dispatch_handle.await {
Ok(result) => return result,
Err(err) if err.is_cancelled() => {}
Err(err) => return Err(Self::tool_task_join_error(err)),
}
dispatch_handle.abort();
match dispatch_handle.await {
Ok(result) => return result,
Err(err) if err.is_cancelled() => {}
Err(err) => return Err(Self::tool_task_join_error(err)),
}
let response = Self::aborted_response(&call, secs);
notify_tool_aborted(
@@ -266,12 +252,7 @@ impl ToolCallRuntime {
}
fn abort_message(call: &ToolCall, secs: f32) -> String {
if call.tool_name.is_default_namespace()
&& matches!(
call.tool_name.name.as_str(),
"exec_command" | "unified_exec"
)
{
if call.tool_name.is_default_namespace() && call.tool_name.name == "exec_command" {
format!("Wall time: {secs:.1} seconds\naborted by user")
} else {
format!("aborted by user after {secs:.1}s")
@@ -568,90 +549,6 @@ mod tests {
impl CoreToolRuntime for ImmediateHandler {}
struct CancellationCleanupHandler {
tool_name: codex_tools::ToolName,
started: std::sync::Mutex<Option<oneshot::Sender<()>>>,
cleanup_started: std::sync::Mutex<Option<oneshot::Sender<()>>>,
allow_cleanup: Arc<Notify>,
}
impl ToolExecutor<ToolInvocation> for CancellationCleanupHandler {
fn tool_name(&self) -> codex_tools::ToolName {
self.tool_name.clone()
}
fn spec(&self) -> codex_tools::ToolSpec {
codex_tools::ToolSpec::Function(codex_tools::ResponsesApiTool {
name: self.tool_name.name.clone(),
description: "Cancellation cleanup test tool.".to_string(),
strict: false,
defer_loading: None,
parameters: codex_tools::JsonSchema::default(),
output_schema: None,
})
}
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(invocation))
}
}
impl CancellationCleanupHandler {
async fn handle_call(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
let started = self
.started
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
if let Some(started) = started {
let _ = started.send(());
}
invocation.cancellation_token.cancelled().await;
let cleanup_started = self
.cleanup_started
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
if let Some(cleanup_started) = cleanup_started {
let _ = cleanup_started.send(());
}
self.allow_cleanup.notified().await;
Ok(Box::new(FunctionToolOutput::from_text(
"cleanup complete".to_string(),
Some(false),
)) as Box<dyn crate::tools::context::ToolOutput>)
}
}
impl CoreToolRuntime for CancellationCleanupHandler {
fn waits_for_runtime_cancellation(&self) -> bool {
true
}
}
struct FinishRecorder {
records: Arc<std::sync::Mutex<Vec<ToolCallOutcome>>>,
}
impl codex_extension_api::ToolLifecycleContributor for FinishRecorder {
fn on_tool_finish<'a>(
&'a self,
input: codex_extension_api::ToolFinishInput<'a>,
) -> codex_extension_api::ToolLifecycleFuture<'a> {
let records = Arc::clone(&self.records);
let outcome = input.outcome;
Box::pin(async move {
records
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(outcome);
})
}
}
struct BlockingFinishContributor {
records: Arc<std::sync::Mutex<Vec<ToolCallOutcome>>>,
finish_started: std::sync::Mutex<Option<oneshot::Sender<()>>>,
@@ -756,78 +653,4 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn cancellation_waiting_for_runtime_cleanup_emits_only_aborted_lifecycle()
-> anyhow::Result<()> {
let (mut session, turn_context) = crate::session::tests::make_session_and_context().await;
let records = Arc::new(std::sync::Mutex::new(Vec::new()));
let mut builder =
codex_extension_api::ExtensionRegistryBuilder::<crate::config::Config>::new();
builder.tool_lifecycle_contributor(Arc::new(FinishRecorder {
records: Arc::clone(&records),
}));
session.services.extensions = Arc::new(builder.build());
let session = Arc::new(session);
let turn_context = Arc::new(turn_context);
let tool_name = codex_tools::ToolName::plain("cleanup_tool");
let (started_tx, started_rx) = oneshot::channel();
let (cleanup_started_tx, cleanup_started_rx) = oneshot::channel();
let allow_cleanup = Arc::new(Notify::new());
let handler = Arc::new(CancellationCleanupHandler {
tool_name: tool_name.clone(),
started: std::sync::Mutex::new(Some(started_tx)),
cleanup_started: std::sync::Mutex::new(Some(cleanup_started_tx)),
allow_cleanup: Arc::clone(&allow_cleanup),
}) as Arc<dyn CoreToolRuntime>;
let step_context = StepContext::for_test(Arc::clone(&turn_context));
let router = Arc::new(ToolRouter::from_parts(
ToolRegistry::from_tools([handler]),
Vec::new(),
));
let step_context = step_context.with_tool_router_for_test(router);
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
let runtime = ToolCallRuntime::new(session, step_context, tracker);
let cancellation_token = CancellationToken::new();
let call = ToolCall {
tool_name,
call_id: "call-1".to_string(),
payload: ToolPayload::Function {
arguments: "{}".to_string(),
},
encrypted_function_args: None,
};
let response_task =
tokio::spawn(runtime.handle_tool_call(call, cancellation_token.clone()));
started_rx.await.expect("handler should start");
cancellation_token.cancel();
cleanup_started_rx
.await
.expect("handler should start cleanup");
tokio::time::sleep(Duration::from_millis(10)).await;
allow_cleanup.notify_one();
let response = tokio::time::timeout(Duration::from_secs(1), response_task)
.await
.expect("timed out waiting for tool response")
.expect("tool response task should join")?;
let ResponseInputItem::FunctionCallOutput { output, .. } = response else {
anyhow::bail!("cancelled tool should return function output");
};
let FunctionCallOutputBody::Text(text) = output.body else {
anyhow::bail!("cancelled tool output should be text");
};
assert!(text.contains("aborted by user"));
let actual = records
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.drain(..)
.collect::<Vec<_>>();
assert_eq!(vec![ToolCallOutcome::Aborted], actual);
Ok(())
}
}
-11
View File
@@ -85,12 +85,6 @@ pub(crate) trait CoreToolRuntime: ToolExecutor<ToolInvocation> {
)
}
/// Whether cancellation should let the handler finish teardown before the
/// host returns an aborted tool response.
fn waits_for_runtime_cancellation(&self) -> bool {
false
}
fn telemetry_tags(&self, _invocation: &ToolInvocation) -> ToolTelemetryTags {
Vec::new()
}
@@ -478,11 +472,6 @@ impl ToolRegistry {
Some(tool.exposure != ToolExposure::Hidden && tool.runtime.supports_parallel_tool_calls())
}
pub(crate) fn waits_for_runtime_cancellation(&self, name: &ToolName) -> Option<bool> {
let tool = self.tool(name)?;
Some(tool.waits_for_runtime_cancellation())
}
#[expect(
clippy::await_holding_invalid_type,
reason = "tool dispatch must keep active-turn accounting atomic"
-6
View File
@@ -144,12 +144,6 @@ impl ToolRouter {
self.registry.tool(&call.tool_name)
}
pub fn tool_waits_for_runtime_cancellation(&self, call: &ToolCall) -> bool {
self.registry
.waits_for_runtime_cancellation(&call.tool_name)
.unwrap_or(false)
}
#[instrument(level = "trace", skip_all, err)]
pub fn build_tool_call(item: ResponseItem) -> Result<Option<ToolCall>, FunctionCallError> {
match item {
+1 -1
View File
@@ -31,8 +31,8 @@ use std::collections::HashMap;
use std::path::Path;
pub(crate) mod apply_patch;
pub(crate) mod shell;
pub(crate) mod unified_exec;
pub(crate) mod zsh_fork;
pub(crate) fn exec_env_for_sandbox_permissions(
env: &HashMap<String, String>,
@@ -1,3 +0,0 @@
#[cfg(unix)]
pub(crate) mod unix_escalation;
pub(crate) mod zsh_fork_backend;
@@ -23,7 +23,7 @@ use crate::tools::runtimes::apply_zsh_fork_path_prepend;
use crate::tools::runtimes::disable_powershell_profile_for_elevated_windows_sandbox;
use crate::tools::runtimes::exec_env_for_sandbox_permissions;
use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot;
use crate::tools::runtimes::shell::zsh_fork_backend;
use crate::tools::runtimes::zsh_fork;
use crate::tools::sandboxing::Approvable;
use crate::tools::sandboxing::ApprovalAction;
use crate::tools::sandboxing::ExecApprovalRequirement;
@@ -422,14 +422,8 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
)
.map_err(ToolError::Codex)?;
exec_env.exec_server_env_config = req.exec_server_env_config.clone();
match zsh_fork_backend::maybe_prepare_unified_exec(
req,
attempt,
ctx,
exec_env,
zsh_fork_config,
)
.await?
match zsh_fork::maybe_prepare_unified_exec(req, attempt, ctx, exec_env, zsh_fork_config)
.await?
{
Some(prepared) => {
if req.turn_environment.environment.is_remote() {
@@ -27,10 +27,14 @@ pub(crate) async fn maybe_prepare_unified_exec(
imp::maybe_prepare_unified_exec(req, attempt, ctx, exec_request, zsh_fork_config).await
}
#[cfg(unix)]
#[path = "zsh_fork/unix_escalation.rs"]
mod unix_escalation;
#[cfg(unix)]
mod imp {
use super::*;
use crate::tools::runtimes::shell::unix_escalation;
use crate::tools::runtimes::zsh_fork::unix_escalation;
use crate::unified_exec::SpawnLifecycle;
use codex_shell_escalation::ESCALATE_SOCKET_ENV_VAR;
use codex_shell_escalation::EscalationSession;
+18 -18
View File
@@ -90,7 +90,6 @@ use codex_tools::collect_code_mode_exec_prompt_tool_definitions;
use codex_tools::collect_request_plugin_install_entries;
use codex_tools::default_namespace_description;
use codex_tools::request_user_input_available_modes;
use codex_tools::shell_type_for_model_and_features;
use futures::future::BoxFuture;
use std::collections::BTreeMap;
use std::collections::HashMap;
@@ -960,29 +959,30 @@ fn add_shell_tools(context: &CoreToolPlanContext<'_>, registry: &mut ToolRegistr
let turn_context = context.turn_context;
let features = turn_context.config.features.get();
let environment_mode = tool_environment_mode(context.environments);
if !environment_mode.has_environment() {
if !environment_mode.has_environment()
|| !features.enabled(Feature::ShellTool)
|| !features.enabled(Feature::UnifiedExec)
|| matches!(
turn_context.model_info.shell_type,
ConfigShellToolType::Disabled
)
{
return;
}
let allow_login_shell = any_environment_allows_login_shell(context.environments);
let exec_permission_approvals_enabled = features.enabled(Feature::ExecPermissionApprovals);
let include_environment_id = matches!(environment_mode, ToolEnvironmentMode::Multiple);
match shell_type_for_model_and_features(&turn_context.model_info, features) {
ConfigShellToolType::UnifiedExec => {
registry.add(ExecCommandHandler::new(ExecCommandHandlerOptions {
allow_login_shell,
exec_permission_approvals_enabled,
include_environment_id,
include_shell_parameter: unified_exec_should_include_shell_parameter(
turn_context,
context.environments,
),
}));
registry.add(WriteStdinHandler);
}
ConfigShellToolType::Disabled => {}
ConfigShellToolType::Default | ConfigShellToolType::Local => {}
}
registry.add(ExecCommandHandler::new(ExecCommandHandlerOptions {
allow_login_shell,
exec_permission_approvals_enabled,
include_environment_id,
include_shell_parameter: unified_exec_should_include_shell_parameter(
turn_context,
context.environments,
),
}));
registry.add(WriteStdinHandler);
}
fn unified_exec_should_include_shell_parameter(
+8 -26
View File
@@ -662,7 +662,7 @@ async fn request_user_input_stays_direct_in_code_mode_only() {
#[tokio::test]
async fn shell_family_registers_only_unified_exec_tools() {
let plan = probe(|turn| {
set_features(turn, &[Feature::ShellTool, Feature::UnifiedExec]);
set_features(turn, &[Feature::ShellTool]);
set_feature(turn, Feature::ShellZshFork, /*enabled*/ false);
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec;
})
@@ -679,7 +679,6 @@ async fn login_shell_parameter_follows_selected_environment() {
for allow_login_shell in [false, true] {
let plan = probe(|turn| {
set_feature(turn, Feature::ShellTool, /*enabled*/ true);
set_feature(turn, Feature::UnifiedExec, /*enabled*/ true);
set_feature(turn, Feature::ShellZshFork, /*enabled*/ false);
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec;
update_config(turn, |config| {
@@ -715,7 +714,7 @@ async fn login_shell_parameter_follows_selected_environment() {
#[tokio::test]
async fn login_shell_parameter_is_available_when_any_environment_allows_it() {
let plan = probe(|turn| {
set_features(turn, &[Feature::ShellTool, Feature::UnifiedExec]);
set_features(turn, &[Feature::ShellTool]);
set_feature(turn, Feature::ShellZshFork, /*enabled*/ false);
update_config(turn, |config| {
config.permissions.allow_login_shell = false;
@@ -756,8 +755,8 @@ async fn disabling_shell_tools_disables_command_tools_for_all_environments() {
);
})
.await;
remote_environment.assert_visible_lacks(&["shell_command", "exec_command", "write_stdin"]);
remote_environment.assert_registered_lacks(&["shell_command", "exec_command", "write_stdin"]);
remote_environment.assert_visible_lacks(&["exec_command", "write_stdin"]);
remote_environment.assert_registered_lacks(&["exec_command", "write_stdin"]);
let multiple_local_environments = probe(|turn| {
set_feature(turn, Feature::ShellTool, /*enabled*/ false);
@@ -765,16 +764,8 @@ async fn disabling_shell_tools_disables_command_tools_for_all_environments() {
duplicate_primary_environment(turn);
})
.await;
multiple_local_environments.assert_visible_lacks(&[
"shell_command",
"exec_command",
"write_stdin",
]);
multiple_local_environments.assert_registered_lacks(&[
"shell_command",
"exec_command",
"write_stdin",
]);
multiple_local_environments.assert_visible_lacks(&["exec_command", "write_stdin"]);
multiple_local_environments.assert_registered_lacks(&["exec_command", "write_stdin"]);
}
#[tokio::test]
@@ -808,7 +799,7 @@ async fn dynamic_tools_cannot_reclaim_the_reserved_exec_command_name() {
#[tokio::test]
async fn shell_zsh_fork_keeps_unified_exec_available() {
let without_composition = probe(|turn| {
set_features(turn, &[Feature::ShellTool, Feature::UnifiedExec]);
set_features(turn, &[Feature::ShellTool]);
set_feature(turn, Feature::ShellZshFork, /*enabled*/ true);
set_feature(turn, Feature::UnifiedExecZshFork, /*enabled*/ false);
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec;
@@ -823,7 +814,6 @@ async fn shell_zsh_fork_keeps_unified_exec_available() {
turn,
&[
Feature::ShellTool,
Feature::UnifiedExec,
Feature::ShellZshFork,
Feature::UnifiedExecZshFork,
],
@@ -847,7 +837,6 @@ async fn zsh_fork_unified_exec_hides_shell_parameter() {
turn,
&[
Feature::ShellTool,
Feature::UnifiedExec,
Feature::ShellZshFork,
Feature::UnifiedExecZshFork,
],
@@ -872,7 +861,6 @@ async fn zsh_fork_unified_exec_keeps_shell_parameter_when_remote_environment_ava
turn,
&[
Feature::ShellTool,
Feature::UnifiedExec,
Feature::ShellZshFork,
Feature::UnifiedExecZshFork,
],
@@ -923,8 +911,6 @@ async fn zsh_fork_unified_exec_keeps_shell_parameter_when_remote_environment_ava
.await;
plan.assert_visible_contains(&["exec_command", "write_stdin"]);
plan.assert_visible_lacks(&["shell_command"]);
plan.assert_registered_lacks(&["shell_command"]);
assert!(has_parameter(plan.visible_spec("exec_command"), "shell"));
assert!(has_parameter(
plan.visible_spec("exec_command"),
@@ -960,7 +946,6 @@ async fn environment_count_controls_environment_backed_tools() {
let multiple_environments = probe(|turn| {
duplicate_primary_environment(turn);
set_feature(turn, Feature::ShellTool, /*enabled*/ true);
set_feature(turn, Feature::UnifiedExec, /*enabled*/ true);
set_feature(turn, Feature::RequestPermissionsTool, /*enabled*/ true);
Arc::make_mut(&mut turn.model_info).apply_patch_tool_type =
Some(ApplyPatchToolType::Freeform);
@@ -972,8 +957,6 @@ async fn environment_count_controls_environment_backed_tools() {
"view_image",
"request_permissions",
]);
multiple_environments.assert_visible_lacks(&["shell_command"]);
multiple_environments.assert_registered_lacks(&["shell_command"]);
assert!(has_parameter(
multiple_environments.visible_spec("exec_command"),
"environment_id"
@@ -990,7 +973,6 @@ async fn environment_count_controls_environment_backed_tools() {
#[tokio::test]
async fn environment_tools_follow_the_step_context() {
let (_session, mut turn) = make_session_and_context().await;
set_feature(&mut turn, Feature::UnifiedExec, /*enabled*/ true);
Arc::make_mut(&mut turn.model_info).apply_patch_tool_type = Some(ApplyPatchToolType::Freeform);
let environments = turn.environments.clone();
@@ -1346,7 +1328,7 @@ async fn strict_namespace_ownership_requires_tool_namespace_inventory_opt_in() {
async fn unified_tool_runtimes_preserve_source_order_and_collision_priority() {
let plan = probe_with(
|turn| {
set_features(turn, &[Feature::ShellTool, Feature::UnifiedExec]);
set_features(turn, &[Feature::ShellTool]);
set_feature(turn, Feature::ShellZshFork, /*enabled*/ false);
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec;
},