Standardize shell execution on unified exec (#39757)

## What changed

- Remove the legacy `shell_command` handler and runtime, leaving `exec_command`
  and `write_stdin` as the shell execution tools.
- Treat legacy `shell_command` model metadata as `unified_exec`, and normalize
  legacy user opt-outs so they do not disable command execution. Managed feature
  requirements and `shell_tool` can still disable it.
- Preserve shell approvals, sandboxing, zsh-fork support, and output truncation
  through the unified execution path.

## Testing

- Cover legacy configuration and model-metadata compatibility.
- Exercise unified shell execution, approvals, truncation, and `apply_patch`
  serialization across the app-server and core test suites.

GitOrigin-RevId: 5c2fd6164fc3519cdae4944cb9db276b8467311c
This commit is contained in:
jif
2026-08-20 17:46:05 +00:00
committed by copyberry
parent d0cc662b8c
commit 8a40095ea3
116 changed files with 1338 additions and 4711 deletions
+4 -74
View File
@@ -1257,7 +1257,7 @@ async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> an
_req: &TurnEnvironment,
call_id: &str,
) -> std::io::Result<crate::tools::sandboxing::ApprovalAction> {
Ok(crate::tools::sandboxing::ApprovalAction::Shell {
Ok(crate::tools::sandboxing::ApprovalAction::ExecCommand {
id: call_id.to_string(),
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
command: Vec::new(),
@@ -1266,6 +1266,7 @@ async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> an
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: None,
tty: false,
proposed_execpolicy_amendment: None,
})
}
@@ -11089,7 +11090,7 @@ async fn fatal_tool_error_stops_turn_and_reports_error() {
id: None,
status: None,
call_id: "call-1".to_string(),
name: "shell_command".to_string(),
name: "exec_command".to_string(),
namespace: None,
input: "{}".to_string(),
internal_chat_message_metadata_passthrough: None,
@@ -11116,7 +11117,7 @@ async fn fatal_tool_error_stops_turn_and_reports_error() {
FunctionCallError::Fatal(message) => {
assert_eq!(
message,
"tool shell_command invoked with incompatible payload"
"tool exec_command invoked with incompatible payload"
);
}
other => panic!("expected FunctionCallError::Fatal, got {other:?}"),
@@ -11300,77 +11301,6 @@ async fn sample_rollout(
)
}
#[cfg(unix)]
#[tokio::test]
async fn shell_tool_cancellation_waits_for_runtime_cleanup() -> anyhow::Result<()> {
let session = make_session_with_config(|config| {
let cwd = config.cwd.clone();
config
.permissions
.set_legacy_sandbox_policy(SandboxPolicy::DangerFullAccess, cwd.as_path())
.expect("test setup should allow sandbox policy");
})
.await?;
let turn_context = session.new_default_turn().await;
let session = Arc::new(session);
let turn_context = Arc::new(turn_context);
let temp_dir = tempfile::TempDir::new()?;
let ready_marker = temp_dir.path().join("ready");
let cleanup_marker = temp_dir.path().join("cleanup");
// Interrupt after the shell starts, then verify dispatch waits for its TERM cleanup trap.
let command = format!(
r#"trap 'printf cleaned > "{}"; exit 0' TERM
printf ready > "{}"
while :; do sleep 1; done"#,
cleanup_marker.display(),
ready_marker.display(),
);
let item = ResponseItem::FunctionCall {
id: None,
name: "shell_command".to_string(),
namespace: None,
arguments: serde_json::json!({
"command": command,
"timeout_ms": 60_000,
})
.to_string(),
call_id: "shell-cleanup-call".to_string(),
encrypted_function_args: None,
internal_chat_message_metadata_passthrough: None,
};
let call = ToolRouter::build_tool_call(item)?
.expect("shell command response item should build a tool call");
let cancellation_token = CancellationToken::new();
let cancellation_tx = cancellation_token.clone();
let handle = tokio::spawn(
test_tool_runtime(Arc::clone(&session), Arc::clone(&turn_context))
.handle_tool_call(call, cancellation_token),
);
let mut ready = false;
for _ in 0..50 {
if ready_marker.exists() {
ready = true;
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
if !ready {
cancellation_tx.cancel();
let _ = timeout(Duration::from_secs(5), handle).await;
anyhow::bail!("shell command should reach the ready marker");
}
cancellation_tx.cancel();
timeout(Duration::from_secs(5), handle)
.await
.expect("cancelled shell tool should finish promptly")
.expect("shell tool task should join")
.expect("cancelled shell tool should return a response item");
assert_eq!(std::fs::read_to_string(cleanup_marker)?, "cleaned");
Ok(())
}
#[tokio::test]
async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request() {
use crate::sandboxing::SandboxPermissions;
@@ -308,7 +308,7 @@ async fn request_permissions_guardian_review_stops_when_cancelled() {
}
#[tokio::test]
async fn guardian_allows_shell_command_additional_permissions_requests_past_policy_validation() {
async fn guardian_allows_exec_command_additional_permissions_requests_past_policy_validation() {
let server = start_mock_server().await;
let _request_log = mount_sse_once(
&server,
@@ -371,11 +371,9 @@ async fn guardian_allows_shell_command_additional_permissions_requests_past_poli
);
let session = Arc::new(session);
let turn_context = Arc::new(turn_context_raw);
let expiration_ms: u64 = if cfg!(windows) { 2_500 } else { 1_000 };
let yield_time_ms: u64 = 10_000;
let handler = crate::tools::handlers::ShellCommandHandler::from(
codex_tools::ShellCommandBackendConfig::Classic,
);
let handler = crate::tools::handlers::ExecCommandHandler::default();
#[allow(deprecated)]
let workdir = Some(turn_context.cwd.to_string_lossy().to_string());
let step_context = StepContext::for_test(Arc::clone(&turn_context));
@@ -387,14 +385,14 @@ async fn guardian_allows_shell_command_additional_permissions_requests_past_poli
cancellation_token: CancellationToken::new(),
tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())),
call_id: "test-call".to_string(),
tool_name: codex_tools::ToolName::plain("shell_command"),
tool_name: codex_tools::ToolName::plain("exec_command"),
source: crate::tools::context::ToolCallSource::Direct,
payload: ToolPayload::Function {
arguments: serde_json::json!({
"command": "echo hi",
"cmd": "echo hi",
"login": false,
"workdir": workdir,
"timeout_ms": expiration_ms,
"yield_time_ms": yield_time_ms,
"sandbox_permissions": SandboxPermissions::WithAdditionalPermissions,
"additional_permissions": PermissionProfile {
network: Some(NetworkPermissions {
@@ -414,7 +412,7 @@ async fn guardian_allows_shell_command_additional_permissions_requests_past_poli
}
#[tokio::test]
async fn strict_auto_review_turn_grant_forces_guardian_for_shell_command_policy_skip() {
async fn strict_auto_review_turn_grant_forces_guardian_for_exec_command_policy_skip() {
let server = start_mock_server().await;
let guardian_request_log = mount_sse_once(
&server,
@@ -501,9 +499,7 @@ async fn strict_auto_review_turn_grant_forces_guardian_for_shell_command_policy_
)
.await;
let handler = crate::tools::handlers::ShellCommandHandler::from(
codex_tools::ShellCommandBackendConfig::Classic,
);
let handler = crate::tools::handlers::ExecCommandHandler::default();
#[allow(deprecated)]
let workdir = Some(turn_context.cwd.to_string_lossy().to_string());
let step_context = StepContext::for_test(Arc::clone(&turn_context));
@@ -515,14 +511,14 @@ async fn strict_auto_review_turn_grant_forces_guardian_for_shell_command_policy_
cancellation_token: CancellationToken::new(),
tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())),
call_id: "strict-shell-command-call".to_string(),
tool_name: codex_tools::ToolName::plain("shell_command"),
tool_name: codex_tools::ToolName::plain("exec_command"),
source: ToolCallSource::Direct,
payload: ToolPayload::Function {
arguments: serde_json::json!({
"command": "echo hi",
"cmd": "echo hi",
"login": false,
"workdir": workdir,
"timeout_ms": 1_000_u64,
"yield_time_ms": 10_000_u64,
})
.to_string(),
},
@@ -665,7 +661,7 @@ async fn process_compacted_history_preserves_separate_guardian_developer_message
clippy::await_holding_invalid_type,
reason = "test mutates active turn state directly to seed granted permissions"
)]
async fn shell_command_allows_sticky_turn_permissions_without_inline_request_permissions_feature() {
async fn exec_command_allows_sticky_turn_permissions_without_inline_request_permissions_feature() {
let (mut session, turn_context_raw) = make_session_and_context().await;
session
.features
@@ -690,9 +686,7 @@ async fn shell_command_allows_sticky_turn_permissions_without_inline_request_per
let session = Arc::new(session);
let turn_context = Arc::new(turn_context_raw);
let handler = crate::tools::handlers::ShellCommandHandler::from(
codex_tools::ShellCommandBackendConfig::Classic,
);
let handler = crate::tools::handlers::ExecCommandHandler::default();
#[allow(deprecated)]
let workdir = Some(turn_context.cwd.to_string_lossy().to_string());
let step_context = StepContext::for_test(Arc::clone(&turn_context));
@@ -704,13 +698,13 @@ async fn shell_command_allows_sticky_turn_permissions_without_inline_request_per
cancellation_token: CancellationToken::new(),
tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())),
call_id: "sticky-turn-grant".to_string(),
tool_name: codex_tools::ToolName::plain("shell_command"),
tool_name: codex_tools::ToolName::plain("exec_command"),
source: crate::tools::context::ToolCallSource::Direct,
payload: ToolPayload::Function {
arguments: serde_json::json!({
"command": "echo hi",
"cmd": "echo hi",
"login": false,
"timeout_ms": 1_000_u64,
"yield_time_ms": 10_000_u64,
"workdir": workdir,
})
.to_string(),