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
-1
View File
@@ -4325,7 +4325,6 @@ dependencies = [
"codex-utils-absolute-path",
"codex-utils-cargo-bin",
"codex-utils-output-truncation",
"codex-utils-pty",
"codex-utils-string",
"jsonptr",
"pretty_assertions",
+2 -2
View File
@@ -37,12 +37,12 @@ pub use mock_model_server::create_mock_responses_server_sequence_unchecked;
pub use models_cache::write_models_cache;
pub use models_cache::write_models_cache_with_models;
pub use responses::create_apply_patch_sse_response;
pub use responses::create_escalated_shell_command_sse_response;
pub use responses::create_command_execution_sse_response;
pub use responses::create_escalated_command_execution_sse_response;
pub use responses::create_exec_command_sse_response;
pub use responses::create_final_assistant_message_sse_response;
pub use responses::create_request_permissions_sse_response;
pub use responses::create_request_user_input_sse_response;
pub use responses::create_shell_command_sse_response;
pub use rollout::create_fake_paginated_rollout;
pub use rollout::create_fake_parented_rollout_with_source;
pub use rollout::create_fake_rollout;
@@ -21,7 +21,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
description: Some(preset.description.clone()),
default_reasoning_level: Some(preset.default_reasoning_effort.clone()),
supported_reasoning_levels: preset.supported_reasoning_efforts.clone(),
shell_type: ConfigShellToolType::ShellCommand,
shell_type: ConfigShellToolType::UnifiedExec,
visibility: if preset.show_in_picker {
ModelVisibility::List
} else {
+14 -12
View File
@@ -2,27 +2,29 @@ use core_test_support::responses;
use serde_json::json;
use std::path::Path;
pub fn create_shell_command_sse_response(
pub fn create_command_execution_sse_response(
command: Vec<String>,
workdir: Option<&Path>,
timeout_ms: Option<u64>,
call_id: &str,
) -> anyhow::Result<String> {
// The `arguments` for the `shell_command` tool is a serialized JSON object.
let command_str = shlex::try_join(command.iter().map(String::as_str))?;
let tool_call_arguments = serde_json::to_string(&json!({
"command": command_str,
let mut arguments = json!({
"cmd": command_str,
"workdir": workdir.map(|w| w.to_string_lossy()),
"timeout_ms": timeout_ms
}))?;
});
if let Some(timeout_ms) = timeout_ms {
arguments["yield_time_ms"] = json!(timeout_ms);
}
let tool_call_arguments = serde_json::to_string(&arguments)?;
Ok(responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call(call_id, "shell_command", &tool_call_arguments),
responses::ev_function_call(call_id, "exec_command", &tool_call_arguments),
responses::ev_completed("resp-1"),
]))
}
pub fn create_escalated_shell_command_sse_response(
pub fn create_escalated_command_execution_sse_response(
command: Vec<String>,
workdir: Option<&Path>,
timeout_ms: Option<u64>,
@@ -30,15 +32,15 @@ pub fn create_escalated_shell_command_sse_response(
) -> anyhow::Result<String> {
let command_str = shlex::try_join(command.iter().map(String::as_str))?;
let tool_call_arguments = serde_json::to_string(&json!({
"command": command_str,
"cmd": command_str,
"workdir": workdir.map(|w| w.to_string_lossy()),
"timeout_ms": timeout_ms,
"yield_time_ms": timeout_ms,
"sandbox_permissions": "require_escalated",
"justification": "Test approval request."
}))?;
Ok(responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call(call_id, "shell_command", &tool_call_arguments),
responses::ev_function_call(call_id, "exec_command", &tool_call_arguments),
responses::ev_completed("resp-1"),
]))
}
@@ -57,7 +59,7 @@ pub fn create_apply_patch_sse_response(
) -> anyhow::Result<String> {
Ok(responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_apply_patch_shell_command_call_via_heredoc(call_id, patch_content),
responses::ev_apply_patch_exec_command_call_via_heredoc(call_id, patch_content),
responses::ev_completed("resp-1"),
]))
}
+15 -56
View File
@@ -4,7 +4,6 @@ use app_test_support::DEFAULT_CLIENT_NAME;
use app_test_support::TestAppServer;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_sequence;
use app_test_support::create_shell_command_sse_response;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url;
@@ -256,12 +255,6 @@ pub(crate) fn assert_basic_thread_initialized_event(
const METRICS_PLUGIN_ID: &str = "sample@openai-curated";
const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
#[derive(Clone, Copy)]
enum PluginMetricsRuntime {
Classic,
Unified { remote: bool, background: bool },
}
fn write_curated_metrics_plugin(codex_home: &Path) -> Result<PathBuf> {
let plugin_id = PluginId::parse(METRICS_PLUGIN_ID)?;
let plugin_root = PluginStore::new(codex_home.to_path_buf()).plugin_root(
@@ -314,7 +307,7 @@ printf '%s' '{"version":1,"measurements":[{"name":"findings","value":3,"dimensio
Ok(script_path.into_path_buf())
}
async fn assert_plugin_measurement_analytics(runtime: PluginMetricsRuntime) -> Result<()> {
async fn assert_plugin_measurement_analytics(remote: bool, background: bool) -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_remote!(
Ok(()),
@@ -324,11 +317,6 @@ async fn assert_plugin_measurement_analytics(runtime: PluginMetricsRuntime) -> R
let codex_home = TempDir::new()?;
let script_path = write_curated_metrics_plugin(codex_home.path())?.canonicalize()?;
let (remote, background) = match runtime {
PluginMetricsRuntime::Classic => (false, false),
PluginMetricsRuntime::Unified { remote, background } => (remote, background),
};
let unified_exec = matches!(runtime, PluginMetricsRuntime::Unified { .. });
let mut command = vec![
"/bin/sh".to_string(),
script_path.to_string_lossy().into_owned(),
@@ -337,22 +325,15 @@ async fn assert_plugin_measurement_analytics(runtime: PluginMetricsRuntime) -> R
command.push("1.0".to_string());
}
let call_id = "curated-plugin-metrics";
let command_response = match runtime {
PluginMetricsRuntime::Classic => {
create_shell_command_sse_response(command, /*workdir*/ None, Some(5_000), call_id)?
}
PluginMetricsRuntime::Unified { .. } => {
let arguments = serde_json::to_string(&json!({
"cmd": shlex::try_join(command.iter().map(String::as_str))?,
"yield_time_ms": if background { 10 } else { 1_000 },
}))?;
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call(call_id, "exec_command", &arguments),
responses::ev_completed("resp-1"),
])
}
};
let arguments = serde_json::to_string(&json!({
"cmd": shlex::try_join(command.iter().map(String::as_str))?,
"yield_time_ms": if background { 10 } else { 1_000 },
}))?;
let command_response = responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call(call_id, "exec_command", &arguments),
responses::ev_completed("resp-1"),
]);
let final_response = create_final_assistant_message_sse_response("done")?;
let server =
create_mock_responses_server_sequence(vec![command_response, final_response]).await;
@@ -372,7 +353,7 @@ async fn assert_plugin_measurement_analytics(runtime: PluginMetricsRuntime) -> R
[features]
plugins = true
remote_plugin = false
unified_exec = {unified_exec}
unified_exec = true
shell_zsh_fork = false
unified_exec_zsh_fork = false
@@ -577,49 +558,27 @@ enabled = true
Ok(())
}
#[cfg_attr(windows, ignore = "plugin metrics fixture is Unix-only")]
#[tokio::test]
async fn classic_plugin_script_emits_measurement_analytics() -> Result<()> {
assert_plugin_measurement_analytics(PluginMetricsRuntime::Classic).await
}
#[cfg_attr(windows, ignore = "plugin metrics fixture is Unix-only")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unified_plugin_script_emits_measurement_analytics() -> Result<()> {
assert_plugin_measurement_analytics(PluginMetricsRuntime::Unified {
remote: false,
background: false,
})
.await
assert_plugin_measurement_analytics(/*remote*/ false, /*background*/ false).await
}
#[cfg_attr(windows, ignore = "plugin metrics fixture is Unix-only")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_unified_plugin_script_emits_measurement_analytics() -> Result<()> {
assert_plugin_measurement_analytics(PluginMetricsRuntime::Unified {
remote: true,
background: false,
})
.await
assert_plugin_measurement_analytics(/*remote*/ true, /*background*/ false).await
}
#[cfg_attr(windows, ignore = "plugin metrics fixture is Unix-only")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unified_background_plugin_script_emits_measurements_after_turn_completion() -> Result<()> {
assert_plugin_measurement_analytics(PluginMetricsRuntime::Unified {
remote: false,
background: true,
})
.await
assert_plugin_measurement_analytics(/*remote*/ false, /*background*/ true).await
}
#[cfg_attr(windows, ignore = "plugin metrics fixture is Unix-only")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_unified_background_plugin_script_emits_measurements_after_turn_completion()
-> Result<()> {
assert_plugin_measurement_analytics(PluginMetricsRuntime::Unified {
remote: true,
background: true,
})
.await
assert_plugin_measurement_analytics(/*remote*/ true, /*background*/ true).await
}
@@ -2,9 +2,9 @@ use anyhow::Context;
use anyhow::Result;
use app_test_support::MockResponsesConfig;
use app_test_support::TestAppServer;
use app_test_support::create_command_execution_sse_response;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_sequence_unchecked;
use app_test_support::create_shell_command_sse_response;
use codex_app_server_protocol::CommandExecutionStatus;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ItemStartedNotification;
@@ -2942,7 +2942,7 @@ async fn websocket_v2_tool_call_delegated_turn_can_execute_shell_tool() -> Resul
// calls the `background_agent` function; the shell command is requested by the delegated
// background agent Responses turn that app-server starts after receiving that function call.
let main_loop = main_loop_responses(vec![
create_shell_command_sse_response(
create_command_execution_sse_response(
realtime_tool_ok_command(),
/*workdir*/ None,
// Windows CI can spend several seconds starting the nested PowerShell command. This
+2 -2
View File
@@ -1,10 +1,10 @@
use anyhow::Result;
use app_test_support::MockResponsesConfig;
use app_test_support::TestAppServer;
use app_test_support::create_command_execution_sse_response;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_repeating_assistant;
use app_test_support::create_mock_responses_server_sequence;
use app_test_support::create_shell_command_sse_response;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ItemStartedNotification;
@@ -195,7 +195,7 @@ async fn review_start_runs_review_turn_and_emits_code_review_item() -> Result<()
#[ignore = "TODO(owenlin0): flaky"]
async fn review_start_exec_approval_item_id_matches_command_execution_item() -> Result<()> {
let responses = vec![
create_shell_command_sse_response(
create_command_execution_sse_response(
vec![
"git".to_string(),
"rev-parse".to_string(),
@@ -4,7 +4,7 @@ use anyhow::Context;
use anyhow::Result;
use app_test_support::MockResponsesConfig;
use app_test_support::TestAppServer;
use app_test_support::create_escalated_shell_command_sse_response;
use app_test_support::create_escalated_command_execution_sse_response;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_sequence;
use app_test_support::create_mock_responses_server_sequence_unchecked;
@@ -888,7 +888,7 @@ fn blocked_turn_response() -> Result<String> {
"import time; time.sleep(10)".to_string(),
];
create_escalated_shell_command_sse_response(
create_escalated_command_execution_sse_response(
shell_command,
/*workdir*/ None,
/*timeout_ms*/ Some(10_000),
@@ -3,6 +3,7 @@ use app_test_support::ChatGptAuthFixture;
use app_test_support::MockResponsesConfig;
use app_test_support::TestAppServer;
use app_test_support::create_apply_patch_sse_response;
use app_test_support::create_command_execution_sse_response;
use app_test_support::create_fake_paginated_rollout;
use app_test_support::create_fake_rollout;
use app_test_support::create_fake_rollout_with_text_elements;
@@ -10,7 +11,6 @@ use app_test_support::create_fake_rollout_with_token_usage;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_repeating_assistant;
use app_test_support::create_mock_responses_server_sequence_unchecked;
use app_test_support::create_shell_command_sse_response;
use app_test_support::rollout_path;
use app_test_support::test_absolute_path;
use app_test_support::to_response;
@@ -4507,7 +4507,7 @@ async fn thread_resume_replays_pending_command_execution_request_approval() -> R
let responses = vec![
create_final_assistant_message_sse_response("seeded")?,
create_shell_command_sse_response(
create_command_execution_sse_response(
vec![
"python3".to_string(),
"-c".to_string(),
@@ -1,7 +1,7 @@
use anyhow::Result;
use app_test_support::MockResponsesConfig;
use app_test_support::TestAppServer;
use app_test_support::create_escalated_shell_command_sse_response;
use app_test_support::create_escalated_command_execution_sse_response;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_sequence;
use app_test_support::format_with_current_shell_display;
@@ -202,7 +202,7 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> {
std::fs::create_dir(&workspace)?;
let responses = vec![
create_escalated_shell_command_sse_response(
create_escalated_command_execution_sse_response(
vec![
"python3".to_string(),
"-c".to_string(),
@@ -3,10 +3,10 @@
use anyhow::Result;
use app_test_support::MockResponsesConfig;
use app_test_support::TestAppServer;
use app_test_support::create_command_execution_sse_response;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_sequence;
use app_test_support::create_mock_responses_server_sequence_unchecked;
use app_test_support::create_shell_command_sse_response;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::RequestId;
@@ -53,14 +53,15 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> {
std::fs::create_dir(&working_directory)?;
// Mock server: long-running shell command then (after abort) nothing else needed.
let server =
create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response(
let server = create_mock_responses_server_sequence_unchecked(vec![
create_command_execution_sse_response(
shell_command.clone(),
Some(&working_directory),
Some(10_000),
"call_sleep",
)?])
.await;
)?,
])
.await;
MockResponsesConfig::new(&server.uri())
.with_sandbox_mode("workspace-write")
.with_root_config(r#"approvals_reviewer = "user""#)
@@ -218,13 +219,14 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<()
let working_directory = tmp.path().join("workdir");
std::fs::create_dir(&working_directory)?;
let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response(
shell_command.clone(),
Some(&working_directory),
Some(10_000),
"call_sleep_approval",
)?])
.await;
let server =
create_mock_responses_server_sequence(vec![create_command_execution_sse_response(
shell_command.clone(),
Some(&working_directory),
Some(10_000),
"call_sleep_approval",
)?])
.await;
MockResponsesConfig::new(&server.uri())
.with_approval_policy("on-request")
.with_root_config(r#"approvals_reviewer = "user""#)
@@ -3,14 +3,14 @@ use anyhow::Result;
use app_test_support::MockResponsesConfig;
use app_test_support::TestAppServer;
use app_test_support::create_apply_patch_sse_response;
use app_test_support::create_escalated_shell_command_sse_response;
use app_test_support::create_command_execution_sse_response;
use app_test_support::create_escalated_command_execution_sse_response;
use app_test_support::create_exec_command_sse_response;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_repeating_assistant;
use app_test_support::create_mock_responses_server_sequence;
use app_test_support::create_mock_responses_server_sequence_unchecked;
use app_test_support::create_request_user_input_sse_response;
use app_test_support::create_shell_command_sse_response;
use app_test_support::format_with_current_shell_display;
use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url;
use app_test_support::write_models_cache;
@@ -2242,7 +2242,7 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> {
let first_shell_command = vec![
"python3".to_string(),
"-c".to_string(),
"import sys; print(sys.argv[1].endswith('7890'))".to_string(),
"import sys, time; time.sleep(0.5); print(sys.argv[1].endswith('7890'))".to_string(),
format!("Authorization: Bearer {bearer_token}"),
];
let expected_approval_command = format_with_current_shell_display(&shlex::try_join(
@@ -2254,14 +2254,14 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> {
// Mock server: first turn requests a shell call (elicitation), then completes.
// Second turn same, but we'll set approval_policy=never to avoid elicitation.
let responses = vec![
create_escalated_shell_command_sse_response(
create_escalated_command_execution_sse_response(
first_shell_command,
/*workdir*/ None,
Some(5000),
"call1",
)?,
create_final_assistant_message_sse_response("done 1")?,
create_shell_command_sse_response(
create_command_execution_sse_response(
vec![
"python3".to_string(),
"-c".to_string(),
@@ -2387,6 +2387,31 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> {
}
}
let requests = server
.received_requests()
.await
.context("failed to fetch received requests")?;
assert!(
requests.iter().any(|request| {
request.url.path().ends_with("/responses")
&& serde_json::from_slice::<Value>(&request.body)
.ok()
.and_then(|body| {
body["input"].as_array().map(|items| {
items.iter().any(|item| {
item["type"] == "function_call_output"
&& item["call_id"] == "call1"
&& item["output"]
.as_str()
.is_some_and(|output| output.contains("True"))
})
})
})
.unwrap_or(false)
}),
"model request should include the command output confirming the original bearer token"
);
// Second turn with approval_policy=never should not elicit approval
let _: TurnStartResponse = mcp
.request(|request_id| ClientRequest::TurnStart {
@@ -2468,7 +2493,7 @@ async fn run_turn_start_exec_approval_rejection_v2(
expected_approval_command.replace(bearer_token, "[REDACTED_SECRET]");
let responses = vec![
create_escalated_shell_command_sse_response(
create_escalated_command_execution_sse_response(
shell_command,
/*workdir*/ None,
Some(5000),
@@ -2614,6 +2639,12 @@ async fn turn_start_explicit_local_environment_updates_legacy_cwd_between_turns(
let tmp = TempDir::new()?;
let codex_home = tmp.path().join("codex_home");
std::fs::create_dir(&codex_home)?;
let rules_dir = codex_home.join("rules");
std::fs::create_dir(&rules_dir)?;
std::fs::write(
rules_dir.join("default.rules"),
r#"prefix_rule(pattern=["echo"], decision="allow")"#,
)?;
let workspace_root = tmp.path().join("workspace");
std::fs::create_dir(&workspace_root)?;
let first_cwd = workspace_root.join("turn1");
@@ -2622,14 +2653,14 @@ async fn turn_start_explicit_local_environment_updates_legacy_cwd_between_turns(
std::fs::create_dir(&second_cwd)?;
let responses = vec![
create_shell_command_sse_response(
create_command_execution_sse_response(
vec!["echo".to_string(), "first".to_string(), "turn".to_string()],
/*workdir*/ None,
Some(5000),
"call-first",
)?,
create_final_assistant_message_sse_response("done first")?,
create_shell_command_sse_response(
create_command_execution_sse_response(
vec!["echo".to_string(), "second".to_string(), "turn".to_string()],
/*workdir*/ None,
Some(5000),
@@ -4639,7 +4670,7 @@ async fn command_execution_notifications_include_trusted_plugin_id() -> Result<(
}"#,
)?;
let responses = vec![
create_shell_command_sse_response(
create_command_execution_sse_response(
vec![
"/bin/sh".to_string(),
script_path.to_string_lossy().into_owned(),
@@ -9,11 +9,11 @@
use anyhow::Result;
use app_test_support::MockResponsesConfig;
use app_test_support::TestAppServer;
use app_test_support::create_escalated_shell_command_sse_response;
use app_test_support::create_command_execution_sse_response;
use app_test_support::create_escalated_command_execution_sse_response;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_sequence;
use app_test_support::create_mock_responses_server_sequence_unchecked;
use app_test_support::create_shell_command_sse_response;
use codex_app_server_protocol::CommandAction;
use codex_app_server_protocol::CommandExecutionApprovalDecision;
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
@@ -74,7 +74,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> {
let release_marker_escaped = release_marker.to_string_lossy().replace('\'', r#"'\''"#);
let wait_for_interrupt =
format!("while [ ! -f '{release_marker_escaped}' ]; do sleep 0.01; done");
let response = create_shell_command_sse_response(
let response = create_command_execution_sse_response(
vec!["/bin/sh".to_string(), "-c".to_string(), wait_for_interrupt],
/*workdir*/ None,
Some(5000),
@@ -96,7 +96,8 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> {
"never",
&BTreeMap::from([
(Feature::ShellZshFork, true),
(Feature::UnifiedExec, false),
(Feature::UnifiedExec, true),
(Feature::UnifiedExecZshFork, true),
(Feature::ShellSnapshot, false),
]),
)?;
@@ -192,7 +193,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> {
eprintln!("using zsh path for zsh-fork test: {}", zsh_path.display());
let responses = vec![
create_escalated_shell_command_sse_response(
create_escalated_command_execution_sse_response(
vec![
"python3".to_string(),
"-c".to_string(),
@@ -211,7 +212,8 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> {
"on-request",
&BTreeMap::from([
(Feature::ShellZshFork, true),
(Feature::UnifiedExec, false),
(Feature::UnifiedExec, true),
(Feature::UnifiedExecZshFork, true),
(Feature::ShellSnapshot, false),
]),
)?;
@@ -323,7 +325,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> {
};
eprintln!("using zsh path for zsh-fork test: {}", zsh_path.display());
let responses = vec![create_escalated_shell_command_sse_response(
let responses = vec![create_escalated_command_execution_sse_response(
vec![
"python3".to_string(),
"-c".to_string(),
@@ -340,7 +342,8 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> {
"on-request",
&BTreeMap::from([
(Feature::ShellZshFork, true),
(Feature::UnifiedExec, false),
(Feature::UnifiedExec, true),
(Feature::UnifiedExecZshFork, true),
(Feature::ShellSnapshot, false),
]),
)?;
@@ -466,15 +469,15 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
second_file.display()
);
let tool_call_arguments = serde_json::to_string(&serde_json::json!({
"command": shell_command,
"cmd": shell_command,
"workdir": serde_json::Value::Null,
"timeout_ms": 20000
"yield_time_ms": 20000
}))?;
let response = responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call(
"call-zsh-fork-subcommand-decline",
"shell_command",
"exec_command",
&tool_call_arguments,
),
responses::ev_completed("resp-1"),
@@ -495,7 +498,8 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
"on-request",
&BTreeMap::from([
(Feature::ShellZshFork, true),
(Feature::UnifiedExec, false),
(Feature::UnifiedExec, true),
(Feature::UnifiedExecZshFork, true),
(Feature::ShellSnapshot, false),
]),
)?;
@@ -3,9 +3,9 @@
use anyhow::Context;
use anyhow::Result;
use app_test_support::TestAppServer;
use app_test_support::create_command_execution_sse_response;
use app_test_support::create_mock_responses_server_sequence;
use app_test_support::create_mock_responses_server_sequence_unchecked;
use app_test_support::create_shell_command_sse_response;
use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url;
use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE;
use codex_app_server::INVALID_PARAMS_ERROR_CODE;
@@ -124,14 +124,15 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> {
let working_directory = tmp.path().join("workdir");
std::fs::create_dir(&working_directory)?;
let server =
create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response(
let server = create_mock_responses_server_sequence_unchecked(vec![
create_command_execution_sse_response(
shell_command.clone(),
Some(&working_directory),
Some(10_000),
"call_sleep",
)?])
.await;
)?,
])
.await;
write_mock_responses_config_toml_with_chatgpt_base_url(
&codex_home,
&server.uri(),
@@ -237,7 +238,7 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> {
std::fs::create_dir(&working_directory)?;
let server = create_mock_responses_server_sequence_unchecked(vec![
create_shell_command_sse_response(
create_command_execution_sse_response(
shell_command.clone(),
Some(&working_directory),
Some(10_000),
@@ -371,7 +372,7 @@ async fn turn_steer_rejects_context_only_input_without_merging_context() -> Resu
std::fs::create_dir(&working_directory)?;
let server = create_mock_responses_server_sequence_unchecked(vec![
create_shell_command_sse_response(
create_command_execution_sse_response(
vec!["sleep".to_string(), "1".to_string()],
Some(&working_directory),
Some(10_000),
@@ -71,7 +71,7 @@ async fn models_client_hits_models_endpoint() {
description: ReasoningEffort::High.to_string(),
},
],
shell_type: ConfigShellToolType::ShellCommand,
shell_type: ConfigShellToolType::UnifiedExec,
visibility: ModelVisibility::List,
supported_in_api: true,
priority: 1,
+76
View File
@@ -6445,6 +6445,50 @@ async fn legacy_toggles_map_to_features() -> std::io::Result<()> {
Ok(())
}
#[tokio::test]
async fn legacy_unified_exec_disable_flags_do_not_disable_command_execution() -> std::io::Result<()>
{
for cfg in [
ConfigToml {
features: Some(FeaturesToml::from(BTreeMap::from([(
"unified_exec".to_string(),
false,
)]))),
..Default::default()
},
ConfigToml {
experimental_use_unified_exec_tool: Some(false),
..Default::default()
},
] {
let codex_home = TempDir::new()?;
let mut config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert!(config.features.enabled(Feature::UnifiedExec));
assert!(config.features.enabled(Feature::ShellTool));
config
.features
.disable(Feature::UnifiedExec)
.expect("legacy unified-exec toggle should normalize successfully");
assert!(config.features.enabled(Feature::UnifiedExec));
config
.features
.disable(Feature::ShellTool)
.expect("shell tool should remain independently configurable");
assert!(!config.features.enabled(Feature::ShellTool));
assert!(config.features.enabled(Feature::UnifiedExec));
}
Ok(())
}
#[tokio::test]
async fn responses_websocket_features_do_not_change_wire_api() -> std::io::Result<()> {
for feature_key in ["responses_websockets", "responses_websockets_v2"] {
@@ -10977,6 +11021,38 @@ shell_tool = false
Ok(())
}
#[tokio::test]
async fn feature_requirements_can_still_disable_unified_exec() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let mut config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(
r#"
[features]
unified_exec = false
shell_tool = true
"#,
),
)
.build()
.await?;
assert!(!config.features.enabled(Feature::UnifiedExec));
assert!(config.features.enabled(Feature::ShellTool));
config
.features
.enable(Feature::UnifiedExec)
.expect("managed feature mutations should normalize successfully");
assert!(!config.features.enabled(Feature::UnifiedExec));
assert!(config.features.enabled(Feature::ShellTool));
Ok(())
}
#[tokio::test]
async fn feature_requirements_auto_review_disables_guardian_approval() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
@@ -152,6 +152,12 @@ fn normalize_candidate(
mut candidate: Features,
pinned_features: &BTreeMap<Feature, bool>,
) -> Features {
// Legacy user opt-outs selected the removed shell backend. Only managed
// requirements may disable the remaining unified-exec implementation.
if !pinned_features.contains_key(&Feature::UnifiedExec) {
candidate.enable(Feature::UnifiedExec);
}
for (feature, enabled) in pinned_features {
candidate.set_enabled(*feature, *enabled);
}
+1
View File
@@ -205,6 +205,7 @@ impl ExecExpiration {
}
/// If ExecExpiration is a timeout, returns the timeout in milliseconds.
#[cfg(target_os = "windows")]
pub(crate) fn timeout_ms(&self) -> Option<u64> {
match self {
ExecExpiration::Timeout(duration) => Some(duration.as_millis() as u64),
+2 -39
View File
@@ -16,14 +16,6 @@ use super::prompt::guardian_truncate_text;
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum GuardianApprovalRequest {
Shell {
id: String,
command: Vec<String>,
cwd: AbsolutePathBuf,
sandbox_permissions: crate::sandboxing::SandboxPermissions,
additional_permissions: Option<AdditionalPermissionProfile>,
justification: Option<String>,
},
ExecCommand {
id: String,
command: Vec<String>,
@@ -264,22 +256,6 @@ pub(crate) fn guardian_approval_request_to_json(
action: &GuardianApprovalRequest,
) -> serde_json::Result<Value> {
match action {
GuardianApprovalRequest::Shell {
id: _,
command,
cwd,
sandbox_permissions,
additional_permissions,
justification,
} => serialize_command_guardian_action(
"shell",
command,
cwd,
*sandbox_permissions,
additional_permissions.as_ref(),
justification.as_ref(),
/*tty*/ None,
),
GuardianApprovalRequest::ExecCommand {
id: _,
command,
@@ -388,9 +364,6 @@ pub(crate) fn guardian_assessment_action(
action: &GuardianApprovalRequest,
) -> GuardianAssessmentAction {
match action {
GuardianApprovalRequest::Shell { command, cwd, .. } => {
command_assessment_action(GuardianCommandSource::Shell, command, cwd)
}
GuardianApprovalRequest::ExecCommand { command, cwd, .. } => {
command_assessment_action(GuardianCommandSource::UnifiedExec, command, cwd)
}
@@ -456,14 +429,6 @@ pub(crate) fn guardian_reviewed_action(
request: &GuardianApprovalRequest,
) -> GuardianReviewedAction {
match request {
GuardianApprovalRequest::Shell {
sandbox_permissions,
additional_permissions,
..
} => GuardianReviewedAction::Shell {
sandbox_permissions: *sandbox_permissions,
additional_permissions: additional_permissions.clone(),
},
GuardianApprovalRequest::ExecCommand {
sandbox_permissions,
additional_permissions,
@@ -514,8 +479,7 @@ pub(crate) fn guardian_reviewed_action(
pub(crate) fn guardian_request_target_item_id(request: &GuardianApprovalRequest) -> Option<&str> {
match request {
GuardianApprovalRequest::Shell { id, .. }
| GuardianApprovalRequest::ExecCommand { id, .. }
GuardianApprovalRequest::ExecCommand { id, .. }
| GuardianApprovalRequest::ApplyPatch { id, .. }
| GuardianApprovalRequest::McpToolCall { id, .. }
| GuardianApprovalRequest::RequestPermissions { id, .. } => Some(id),
@@ -532,8 +496,7 @@ pub(crate) fn guardian_request_turn_id<'a>(
match request {
GuardianApprovalRequest::NetworkAccess { turn_id, .. }
| GuardianApprovalRequest::RequestPermissions { turn_id, .. } => turn_id,
GuardianApprovalRequest::Shell { .. }
| GuardianApprovalRequest::ExecCommand { .. }
GuardianApprovalRequest::ExecCommand { .. }
| GuardianApprovalRequest::ApplyPatch { .. }
| GuardianApprovalRequest::McpToolCall { .. } => default_turn_id,
#[cfg(unix)]
+1 -2
View File
@@ -77,8 +77,7 @@ fn plugin_attribution_for_guardian_request(
request: &GuardianApprovalRequest,
) -> Option<PluginCommandAttribution> {
match request {
GuardianApprovalRequest::Shell { command, cwd, .. }
| GuardianApprovalRequest::ExecCommand { command, cwd, .. } => {
GuardianApprovalRequest::ExecCommand { command, cwd, .. } => {
turn.plugin_attribution_for_command(command, cwd)
}
#[cfg(unix)]
+2 -1
View File
@@ -1626,13 +1626,14 @@ mod tests {
parent_session: Arc::new(session),
parent_context: GuardianReviewContext::from(Arc::new(turn)),
spawn_config,
request: GuardianApprovalRequest::Shell {
request: GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
command: vec!["git".to_string(), "status".to_string()],
cwd,
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Inspect repo state.".to_string()),
tty: false,
},
reasons: ApprovalRequestReasons::default(),
schema: super::super::prompt::guardian_output_schema(),
@@ -22,7 +22,7 @@ Scenario: Guardian follow-up review request layout
[12] First retry reason\n\n
[13] Assess the exact planned action below. Use read-only tool checks when local state matters.\n
[14] Planned action JSON:\n
[15] {\n "command": [\n "git",\n "push"\n ],\n "cwd": "/repo/codex-rs/core",\n "justification": "Need to push the first docs fix.",\n "sandbox_permissions": "use_default",\n "tool": "shell"\n}\n
[15] {\n "command": [\n "git",\n "push"\n ],\n "cwd": "/repo/codex-rs/core",\n "justification": "Need to push the first docs fix.",\n "sandbox_permissions": "use_default",\n "tool": "exec_command",\n "tty": false\n}\n
[16] >>> APPROVAL REQUEST END\n
## Follow-up Guardian Review Request
@@ -43,7 +43,7 @@ Scenario: Guardian follow-up review request layout
[12] First retry reason\n\n
[13] Assess the exact planned action below. Use read-only tool checks when local state matters.\n
[14] Planned action JSON:\n
[15] {\n "command": [\n "git",\n "push"\n ],\n "cwd": "/repo/codex-rs/core",\n "justification": "Need to push the first docs fix.",\n "sandbox_permissions": "use_default",\n "tool": "shell"\n}\n
[15] {\n "command": [\n "git",\n "push"\n ],\n "cwd": "/repo/codex-rs/core",\n "justification": "Need to push the first docs fix.",\n "sandbox_permissions": "use_default",\n "tool": "exec_command",\n "tty": false\n}\n
[16] >>> APPROVAL REQUEST END\n
03:message/assistant:{"risk_level":"low","user_authorization":"high","outcome":"allow","rationale":"first guardian rationale from the prior review"}
04:message/developer:Use prior reviews as context, not binding precedent. Follow the Workspace Policy. If the user explicitly approves a previously rejected action after being informed of the concrete risks, set outcome to "allow" unless the policy explicitly disallows user overwrites in such cases.
@@ -60,7 +60,7 @@ Scenario: Guardian follow-up review request layout
[10] Second retry reason\n\n
[11] Assess the exact planned action below. Use read-only tool checks when local state matters.\n
[12] Planned action JSON:\n
[13] {\n "command": [\n "git",\n "push",\n "--force-with-lease"\n ],\n "cwd": "/repo/codex-rs/core",\n "justification": "Need to push the second docs fix.",\n "sandbox_permissions": "use_default",\n "tool": "shell"\n}\n
[13] {\n "command": [\n "git",\n "push",\n "--force-with-lease"\n ],\n "cwd": "/repo/codex-rs/core",\n "justification": "Need to push the second docs fix.",\n "sandbox_permissions": "use_default",\n "tool": "exec_command",\n "tty": false\n}\n
[14] >>> APPROVAL REQUEST END\n
shared_prompt_cache_key: true
@@ -23,5 +23,5 @@ Scenario: Guardian review request layout
[13] Sandbox denied outbound git push to github.com.\n\n
[14] Assess the exact planned action below. Use read-only tool checks when local state matters.\n
[15] Planned action JSON:\n
[16] {\n "command": [\n "git",\n "push",\n "origin",\n "guardian-approval-mvp"\n ],\n "cwd": "/repo/codex-rs/core",\n "justification": "Need to push the reviewed docs fix to the repo remote.",\n "sandbox_permissions": "use_default",\n "tool": "shell"\n}\n
[16] {\n "command": [\n "git",\n "push",\n "origin",\n "guardian-approval-mvp"\n ],\n "cwd": "/repo/codex-rs/core",\n "justification": "Need to push the reviewed docs fix to the repo remote.",\n "sandbox_permissions": "use_default",\n "tool": "exec_command",\n "tty": false\n}\n
[17] >>> APPROVAL REQUEST END\n
+51 -30
View File
@@ -278,14 +278,15 @@ async fn guardian_test_session_turn_and_rx(
(session, turn, rx)
}
fn guardian_shell_request(id: &str) -> GuardianApprovalRequest {
GuardianApprovalRequest::Shell {
fn guardian_exec_command_request(id: &str) -> GuardianApprovalRequest {
GuardianApprovalRequest::ExecCommand {
id: id.to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the reviewed docs fix.".to_string()),
tty: false,
}
}
@@ -482,13 +483,14 @@ async fn build_guardian_prompt_full_mode_preserves_initial_review_format() -> an
let prompt = build_guardian_prompt_items(
session.as_ref(),
Some("Sandbox denied outbound git push to github.com.".to_string()),
GuardianApprovalRequest::Shell {
GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the reviewed docs fix.".to_string()),
tty: false,
},
GuardianPromptMode::Full,
)
@@ -518,13 +520,14 @@ async fn build_guardian_prompt_prefers_retry_reason_over_approval_reason() -> an
approval: Some("A policy rule requires approval.".to_string()),
retry: Some("The sandbox blocked the initial command.".to_string()),
},
GuardianApprovalRequest::Shell {
GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: None,
tty: false,
},
GuardianPromptMode::Full,
/*reviewed_node_repl_evidence_sequence*/ 0,
@@ -556,13 +559,14 @@ async fn build_guardian_prompt_truncates_oversized_approval_reason() -> anyhow::
approval: Some(approval_reason),
retry: None,
},
GuardianApprovalRequest::Shell {
GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: None,
tty: false,
},
GuardianPromptMode::Full,
/*reviewed_node_repl_evidence_sequence*/ 0,
@@ -646,13 +650,14 @@ async fn build_guardian_prompt_includes_parent_turn_denied_reads() -> anyhow::Re
approval: None,
retry: Some("Sandbox denied reading /repo/private/secret.txt.".to_string()),
},
GuardianApprovalRequest::Shell {
GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
command: vec!["cat".to_string(), "/repo/private/secret.txt".to_string()],
cwd: test_path_buf("/repo").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::RequireEscalated,
additional_permissions: None,
justification: Some("Need to inspect the secret file.".to_string()),
tty: false,
},
GuardianPromptMode::Full,
/*reviewed_node_repl_evidence_sequence*/ 0,
@@ -702,13 +707,14 @@ async fn build_guardian_prompt_delta_mode_preserves_original_numbering() -> anyh
let prompt = build_guardian_prompt_items(
session.as_ref(),
/*retry_reason*/ None,
GuardianApprovalRequest::Shell {
GuardianApprovalRequest::ExecCommand {
id: "shell-2".to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the second docs fix.".to_string()),
tty: false,
},
GuardianPromptMode::Delta {
cursor: GuardianTranscriptCursor {
@@ -740,13 +746,14 @@ async fn build_guardian_prompt_delta_mode_handles_empty_delta() -> anyhow::Resul
let prompt = build_guardian_prompt_items(
session.as_ref(),
/*retry_reason*/ None,
GuardianApprovalRequest::Shell {
GuardianApprovalRequest::ExecCommand {
id: "shell-2".to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the second docs fix.".to_string()),
tty: false,
},
GuardianPromptMode::Delta {
cursor: GuardianTranscriptCursor {
@@ -775,13 +782,14 @@ async fn build_guardian_prompt_stale_delta_cursor_falls_back_to_full_prompt() ->
let prompt = build_guardian_prompt_items(
session.as_ref(),
/*retry_reason*/ None,
GuardianApprovalRequest::Shell {
GuardianApprovalRequest::ExecCommand {
id: "shell-3".to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the docs fix.".to_string()),
tty: false,
},
GuardianPromptMode::Delta {
cursor: GuardianTranscriptCursor {
@@ -860,13 +868,14 @@ async fn build_guardian_prompt_stale_delta_version_falls_back_to_full_prompt() -
let prompt = build_guardian_prompt_items(
session.as_ref(),
/*retry_reason*/ None,
GuardianApprovalRequest::Shell {
GuardianApprovalRequest::ExecCommand {
id: "shell-4".to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push after the compaction.".to_string()),
tty: false,
},
GuardianPromptMode::Delta {
cursor: GuardianTranscriptCursor {
@@ -1167,7 +1176,7 @@ async fn build_guardian_prompt_items_keeps_other_requests_generic() -> anyhow::R
guardian_mcp_request("node_repl", "js"),
guardian_mcp_request("node_repl", "inspect"),
guardian_mcp_request("another_server", "js"),
guardian_shell_request("shell-1"),
guardian_exec_command_request("shell-1"),
] {
let prompt = build_guardian_prompt_items_with_parent_turn(
session.as_ref(),
@@ -1732,13 +1741,14 @@ async fn guardian_request_model_for_auto_review(
let (outcome, analytics_result) = run_guardian_review_session_for_test(
Arc::clone(&session),
turn,
GuardianApprovalRequest::Shell {
GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: None,
tty: false,
},
ApprovalRequestReasons {
approval: None,
@@ -1970,7 +1980,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot()
)
.await;
let request = GuardianApprovalRequest::Shell {
let request = GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
command: vec![
"git".to_string(),
@@ -1982,6 +1992,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot()
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the reviewed docs fix to the repo remote.".to_string()),
tty: false,
};
let outcome = run_guardian_review_session_for_test(
@@ -2107,13 +2118,14 @@ async fn build_guardian_prompt_items_includes_parent_session_id() -> anyhow::Res
let prompt = build_guardian_prompt_items(
&session,
/*retry_reason*/ None,
GuardianApprovalRequest::Shell {
GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
command: vec!["git".to_string(), "status".to_string()],
cwd: test_path_buf("/repo").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: None,
tty: false,
},
GuardianPromptMode::Full,
)
@@ -2198,13 +2210,14 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow:
turn_mut.config = Arc::new(config);
seed_guardian_parent_history(&session, &turn).await;
let first_request = GuardianApprovalRequest::Shell {
let first_request = GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the first docs fix.".to_string()),
tty: false,
};
let first_outcome = run_guardian_review_session_for_test(
Arc::clone(&session),
@@ -2244,7 +2257,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow:
],
)
.await;
let second_request = GuardianApprovalRequest::Shell {
let second_request = GuardianApprovalRequest::ExecCommand {
id: "shell-2".to_string(),
command: vec![
"git".to_string(),
@@ -2255,6 +2268,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow:
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the second docs fix.".to_string()),
tty: false,
};
let second_outcome = run_guardian_review_session_for_test(
Arc::clone(&session),
@@ -2317,13 +2331,14 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow:
/*reference_context_item*/ None,
)
.await;
let third_request = GuardianApprovalRequest::Shell {
let third_request = GuardianApprovalRequest::ExecCommand {
id: "shell-3".to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the third docs fix.".to_string()),
tty: false,
};
let third_outcome = run_guardian_review_session_for_test(
Arc::clone(&session),
@@ -2355,7 +2370,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow:
let fourth_outcome = run_guardian_review_session_for_test(
Arc::clone(&session),
Arc::clone(&turn),
guardian_shell_request("shell-4"),
guardian_exec_command_request("shell-4"),
ApprovalRequestReasons::default(),
guardian_output_schema(),
/*external_cancel*/ None,
@@ -2564,13 +2579,14 @@ async fn guardian_reused_trunk_ignores_stale_prior_turn_completion() -> anyhow::
let first_outcome = run_guardian_review_session_for_test(
Arc::clone(&session),
Arc::clone(&turn),
GuardianApprovalRequest::Shell {
GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the first docs fix.".to_string()),
tty: false,
},
ApprovalRequestReasons::default(),
guardian_output_schema(),
@@ -2609,13 +2625,14 @@ async fn guardian_reused_trunk_ignores_stale_prior_turn_completion() -> anyhow::
let second_outcome = run_guardian_review_session_for_test(
Arc::clone(&session),
Arc::clone(&turn),
GuardianApprovalRequest::Shell {
GuardianApprovalRequest::ExecCommand {
id: "shell-2".to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the second docs fix.".to_string()),
tty: false,
},
ApprovalRequestReasons::default(),
guardian_output_schema(),
@@ -2689,13 +2706,14 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() ->
&session,
&turn,
"review-shell-guardian-error".to_string(),
GuardianApprovalRequest::Shell {
GuardianApprovalRequest::ExecCommand {
id: "shell-guardian-error".to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the reviewed docs fix.".to_string()),
tty: false,
},
ApprovalRequestReasons::default(),
)
@@ -2782,7 +2800,7 @@ async fn guardian_review_retries_transient_session_failure_then_approves() -> an
let (outcome, metadata) = run_guardian_review_session_for_test(
Arc::clone(&session),
Arc::clone(&turn),
guardian_shell_request("shell-session-retry"),
guardian_exec_command_request("shell-session-retry"),
ApprovalRequestReasons::default(),
guardian_output_schema(),
/*external_cancel*/ None,
@@ -2824,7 +2842,7 @@ async fn guardian_review_does_not_retry_missing_assessment_payload() -> anyhow::
&session,
&turn,
"review-missing-assessment".to_string(),
guardian_shell_request("shell-missing-assessment"),
guardian_exec_command_request("shell-missing-assessment"),
ApprovalRequestReasons::default(),
)
.await;
@@ -2873,7 +2891,7 @@ async fn guardian_review_retries_two_parse_failures_then_approves() -> anyhow::R
let (outcome, metadata) = run_guardian_review_session_for_test(
Arc::clone(&session),
Arc::clone(&turn),
guardian_shell_request("shell-parse-retry"),
guardian_exec_command_request("shell-parse-retry"),
ApprovalRequestReasons::default(),
guardian_output_schema(),
/*external_cancel*/ None,
@@ -2928,7 +2946,7 @@ async fn guardian_review_exhausts_three_failures_with_one_terminal_event() -> an
&session,
&turn,
"review-exhausted-retry".to_string(),
guardian_shell_request("shell-exhausted-retry"),
guardian_exec_command_request("shell-exhausted-retry"),
ApprovalRequestReasons::default(),
)
.await;
@@ -2979,7 +2997,7 @@ async fn guardian_review_does_not_retry_valid_denial() -> anyhow::Result<()> {
&session,
&turn,
"review-valid-denial".to_string(),
guardian_shell_request("shell-valid-denial"),
guardian_exec_command_request("shell-valid-denial"),
ApprovalRequestReasons::default(),
)
.await;
@@ -3039,7 +3057,7 @@ async fn escalated_retry_bypasses_extension_approval_and_runs_guardian() -> anyh
&session,
&turn,
"review-escalated-retry".to_string(),
guardian_shell_request("shell-escalated-retry"),
guardian_exec_command_request("shell-escalated-retry"),
ApprovalRequestReasons {
approval: None,
retry: Some(retry_reason.to_string()),
@@ -3136,13 +3154,14 @@ async fn guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history() ->
let (session, turn) = guardian_test_session_and_turn_with_base_url(server.uri()).await;
seed_guardian_parent_history(&session, &turn).await;
let initial_request = GuardianApprovalRequest::Shell {
let initial_request = GuardianApprovalRequest::ExecCommand {
id: "shell-guardian-1".to_string(),
command: vec!["git".to_string(), "status".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Inspect repo state before proceeding.".to_string()),
tty: false,
};
assert_eq!(
review_approval_request(
@@ -3179,21 +3198,23 @@ async fn guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history() ->
)
.await;
let second_request = GuardianApprovalRequest::Shell {
let second_request = GuardianApprovalRequest::ExecCommand {
id: "shell-guardian-2".to_string(),
command: vec!["git".to_string(), "diff".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Inspect pending changes before proceeding.".to_string()),
tty: false,
};
let third_request = GuardianApprovalRequest::Shell {
let third_request = GuardianApprovalRequest::ExecCommand {
id: "shell-guardian-3".to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs(),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Inspect whether pushing is safe before proceeding.".to_string()),
tty: false,
};
let session_for_second = Arc::clone(&session);
-4
View File
@@ -4,7 +4,6 @@ use crate::tools::flat_tool_name;
use crate::tools::handlers::unified_exec::ExecCommandArgs;
use codex_memories_read::usage::MEMORIES_USAGE_METRIC;
use codex_memories_read::usage::memories_usage_kinds_from_command;
use codex_protocol::models::ShellCommandToolCallParams;
pub(crate) fn emit_metric_for_tool_read(invocation: &ToolInvocation, success: bool) {
let Some(command) = shell_script_for_invocation(invocation) else {
@@ -36,9 +35,6 @@ pub(crate) fn shell_script_for_invocation(invocation: &ToolInvocation) -> Option
}
match invocation.tool_name.name.as_str() {
"shell_command" => serde_json::from_str::<ShellCommandToolCallParams>(arguments)
.ok()
.map(|params| params.command),
"exec_command" => serde_json::from_str::<ExecCommandArgs>(arguments)
.ok()
.map(|params| params.cmd),
+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(),
+3 -72
View File
@@ -18,7 +18,6 @@ use crate::session::turn_context::TurnContext;
use crate::tools::flat_tool_name;
use crate::tools::hook_names::HookToolName;
use crate::tools::runtimes::apply_patch::ApplyPatchApprovalKey;
use crate::tools::runtimes::shell::ApprovalKey;
use crate::tools::runtimes::unified_exec::UnifiedExecApprovalKey;
use crate::tools::sandboxing::ApprovalRequestReasons;
use crate::tools::sandboxing::PermissionRequestPayload;
@@ -65,17 +64,6 @@ pub(crate) struct ApprovalContext {
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum ApprovalAction {
Shell {
id: String,
environment_id: String,
command: Vec<String>,
hook_command: String,
cwd: PathUri,
sandbox_permissions: SandboxPermissions,
additional_permissions: Option<AdditionalPermissionProfile>,
justification: Option<String>,
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
},
ExecCommand {
id: String,
environment_id: String,
@@ -153,7 +141,6 @@ pub(crate) enum ApprovalAction {
#[derive(Clone, Debug, Eq, Hash, PartialEq, serde::Serialize)]
#[serde(untagged)]
pub(crate) enum ApprovalCacheKey {
Shell(ApprovalKey),
ExecCommand(UnifiedExecApprovalKey),
ApplyPatch(ApplyPatchApprovalKey),
}
@@ -161,12 +148,7 @@ pub(crate) enum ApprovalCacheKey {
impl ApprovalAction {
pub(crate) fn permission_request_payload(&self) -> PermissionRequestPayload {
match self {
Self::Shell {
hook_command,
justification,
..
}
| Self::ExecCommand {
Self::ExecCommand {
hook_command,
justification,
..
@@ -214,20 +196,6 @@ impl ApprovalAction {
pub(crate) fn cache_keys(&self) -> Vec<ApprovalCacheKey> {
match self {
Self::Shell {
environment_id,
command,
cwd,
sandbox_permissions,
additional_permissions,
..
} => vec![ApprovalCacheKey::Shell(ApprovalKey {
environment_id: environment_id.clone(),
command: canonicalize_command_for_approval(command),
cwd: cwd.clone(),
sandbox_permissions: *sandbox_permissions,
additional_permissions: additional_permissions.clone(),
})],
Self::ExecCommand {
environment_id,
command,
@@ -269,23 +237,6 @@ impl ApprovalAction {
fn into_guardian_request(self) -> std::io::Result<crate::guardian::GuardianApprovalRequest> {
Ok(match self {
Self::Shell {
id,
environment_id,
command,
cwd,
sandbox_permissions,
additional_permissions,
justification,
..
} => crate::guardian::GuardianApprovalRequest::Shell {
id,
command,
cwd: guardian_cwd(&environment_id, cwd)?,
sandbox_permissions,
additional_permissions,
justification,
},
Self::ExecCommand {
id,
environment_id,
@@ -663,16 +614,7 @@ impl Session {
ctx: &ApprovalContext,
) -> ReviewDecision {
match action {
ApprovalAction::Shell {
environment_id,
command,
cwd,
additional_permissions,
justification,
proposed_execpolicy_amendment,
..
}
| ApprovalAction::ExecCommand {
ApprovalAction::ExecCommand {
environment_id,
command,
cwd,
@@ -690,18 +632,7 @@ impl Session {
));
}
};
let tool_name = match action {
ApprovalAction::Shell { .. } => "shell",
ApprovalAction::ExecCommand { .. } => "unified_exec",
#[cfg(unix)]
ApprovalAction::Execve { .. } => unreachable!("matched command approval"),
ApprovalAction::ApplyPatch { .. } => unreachable!("matched command approval"),
ApprovalAction::McpToolCall { .. }
| ApprovalAction::NetworkAccess { .. }
| ApprovalAction::RequestPermissions { .. } => {
unreachable!("matched command approval")
}
};
let tool_name = "unified_exec";
let reason = ctx
.retry_reason
.clone()
+35 -6
View File
@@ -367,7 +367,7 @@ impl ToolOutput for ExecCommandToolOutput {
}
Some(JsonValue::String(
self.truncated_output(self.model_output_max_tokens()),
self.truncated_output_with_policy(self.model_output_policy()),
))
}
@@ -405,13 +405,21 @@ impl ToolOutput for ExecCommandToolOutput {
}
impl ExecCommandToolOutput {
fn model_output_max_tokens(&self) -> usize {
resolve_max_tokens(self.max_output_tokens).min(self.truncation_policy.token_budget())
fn model_output_policy(&self) -> TruncationPolicy {
let requested_policy = TruncationPolicy::Tokens(resolve_max_tokens(self.max_output_tokens));
if requested_policy.byte_budget() < self.truncation_policy.byte_budget() {
requested_policy
} else {
self.truncation_policy
}
}
pub(crate) fn truncated_output(&self, max_tokens: usize) -> String {
self.truncated_output_with_policy(TruncationPolicy::Tokens(max_tokens))
}
fn truncated_output_with_policy(&self, policy: TruncationPolicy) -> String {
let text = String::from_utf8_lossy(&self.raw_output).to_string();
let policy = TruncationPolicy::Tokens(max_tokens);
let Some(omitted_bytes) = self.output_omitted_bytes else {
return formatted_truncate_text(&text, policy);
};
@@ -462,9 +470,30 @@ impl ExecCommandToolOutput {
}
sections.push("Output:".to_string());
sections.push(self.truncated_output(self.model_output_max_tokens()));
let header = sections.join("\n");
let output_budget = (self.truncation_policy * 1.2)
.byte_budget()
.saturating_sub(header.len().saturating_add(/*rhs*/ 1));
let mut policy = self.model_output_policy();
let mut output = self.truncated_output_with_policy(policy);
sections.join("\n")
// History applies this same serialization budget to the complete response.
// Reserve room for metadata, warning headers, and the truncation marker so
// it does not truncate an already-truncated output a second time.
while output.len() > output_budget && policy.byte_budget() > 0 {
let excess_bytes = output.len() - output_budget;
policy = match policy {
TruncationPolicy::Bytes(bytes) => {
TruncationPolicy::Bytes(bytes.saturating_sub(excess_bytes))
}
TruncationPolicy::Tokens(tokens) => TruncationPolicy::Tokens(
tokens.saturating_sub(TruncationPolicy::Bytes(excess_bytes).token_budget()),
),
};
output = self.truncated_output_with_policy(policy);
}
format!("{header}\n{output}")
}
}
+46
View File
@@ -467,6 +467,52 @@ fn exec_command_tool_output_formats_truncated_response() {
}
}
#[test]
fn exec_command_tool_output_reserves_metadata_budget_and_preserves_policy_units() {
let payload = ToolPayload::Function {
arguments: "{}".to_string(),
};
let raw_output = (1..=150)
.map(|line| format!("{line}\n"))
.collect::<String>()
.into_bytes();
for (policy, marker) in [
(TruncationPolicy::Bytes(200), "chars truncated"),
(TruncationPolicy::Tokens(50), "tokens truncated"),
] {
let response = ExecCommandToolOutput {
event_call_id: "call-42".to_string(),
chunk_id: "abc123".to_string(),
wall_time: std::time::Duration::from_millis(/*millis*/ 1250),
raw_output: raw_output.clone(),
truncation_policy: policy,
max_output_tokens: None,
process_id: None,
exit_code: Some(0),
original_token_count: Some(123),
output_omitted_bytes: None,
hook_command: None,
}
.to_response_item("call-42", &payload);
let ResponseInputItem::FunctionCallOutput { output, .. } = response else {
panic!("expected FunctionCallOutput");
};
let text = output
.body
.to_text()
.expect("exec output should serialize as text");
assert!(text.len() <= (policy * 1.2).byte_budget());
assert_eq!(text.matches(marker).count(), 1);
assert!(text.contains("Original token count: 123"));
assert!(text.contains("Total output lines: 150"));
assert!(text.contains("\n1\n2\n3\n"));
assert!(text.ends_with("149\n150\n"));
}
}
#[test]
fn exec_command_tool_output_preserves_omission_metadata_when_truncated() {
let payload = ToolPayload::Function {
+1 -54
View File
@@ -26,7 +26,6 @@ use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::PatchApplyStatus;
use codex_protocol::protocol::TurnDiffEvent;
use codex_shell_command::parse_command::parse_command;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use codex_utils_string::truncate_middle_with_token_budget;
use std::collections::HashMap;
@@ -180,13 +179,6 @@ async fn emit_exec_command_begin(ctx: ToolEventCtx<'_>, exec_input: &ExecCommand
}
// Concrete, allocation-free emitter: avoid trait objects and boxed futures.
pub(crate) enum ToolEmitter {
Shell {
command: Vec<String>,
cwd: PathUri,
source: ExecCommandSource,
parsed_cmd: Vec<ParsedCommand>,
plugin_attribution: Option<PluginCommandAttribution>,
},
ApplyPatch {
changes: HashMap<PathBuf, FileChange>,
auto_approved: bool,
@@ -203,22 +195,6 @@ pub(crate) enum ToolEmitter {
}
impl ToolEmitter {
pub fn shell(
command: Vec<String>,
cwd: AbsolutePathBuf,
source: ExecCommandSource,
plugin_attribution: Option<PluginCommandAttribution>,
) -> Self {
let parsed_cmd = parse_command(&command);
Self::Shell {
command,
cwd: PathUri::from_abs_path(&cwd),
source,
parsed_cmd,
plugin_attribution,
}
}
pub fn apply_patch_for_environment(
changes: HashMap<PathBuf, FileChange>,
auto_approved: bool,
@@ -251,33 +227,6 @@ impl ToolEmitter {
pub async fn emit(&self, ctx: ToolEventCtx<'_>, stage: ToolEventStage<'_>) {
match (self, stage) {
(
Self::Shell {
command,
cwd,
source,
parsed_cmd,
plugin_attribution,
..
},
stage,
) => {
emit_exec_stage(
ctx,
ExecCommandInput::new(
command,
cwd,
parsed_cmd,
*source,
/*interaction_input*/ None,
/*process_id*/ None,
plugin_attribution.as_ref(),
),
stage,
)
.await;
}
(
Self::ApplyPatch {
changes,
@@ -491,9 +440,7 @@ impl ToolEmitter {
// TODO: We should add a new ToolError variant for user-declined approvals.
let normalized = if msg == "rejected by user" {
match self {
Self::Shell { .. } | Self::UnifiedExec { .. } => {
"exec command rejected by user".to_string()
}
Self::UnifiedExec { .. } => "exec command rejected by user".to_string(),
Self::ApplyPatch { .. } => "patch rejected by user".to_string(),
}
} else {
-15
View File
@@ -24,7 +24,6 @@ pub(crate) mod request_plugin_install_spec;
mod request_user_input;
pub(crate) mod request_user_input_spec;
mod send_user_message_async;
mod shell;
pub(crate) mod shell_spec;
mod sleep;
mod test_sync;
@@ -70,8 +69,6 @@ pub use request_permissions::RequestPermissionsHandler;
pub use request_plugin_install::RequestPluginInstallHandler;
pub use request_user_input::RequestUserInputHandler;
pub use send_user_message_async::SendUserMessageAsyncHandler;
pub use shell::ShellCommandHandler;
pub(crate) use shell::ShellCommandHandlerOptions;
pub use sleep::SleepHandler;
pub use test_sync::TestSyncHandler;
pub(crate) use tool_search::ToolSearchHandlerCache;
@@ -156,18 +153,6 @@ where
parse_arguments(arguments)
}
fn resolve_workdir_base_path(
arguments: &str,
default_cwd: &AbsolutePathBuf,
) -> Result<AbsolutePathBuf, FunctionCallError> {
let arguments: Value = parse_arguments(arguments)?;
Ok(arguments
.get("workdir")
.and_then(Value::as_str)
.filter(|workdir| !workdir.is_empty())
.map_or_else(|| default_cwd.clone(), |workdir| default_cwd.join(workdir)))
}
fn resolve_tool_environment<'a>(
environments: &'a TurnEnvironmentSnapshot,
environment_id: Option<&str>,
-252
View File
@@ -1,252 +0,0 @@
use codex_features::Feature;
use codex_protocol::models::ShellCommandToolCallParams;
use serde_json::Value as JsonValue;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use crate::exec::ExecParams;
use crate::exec_policy::ExecApprovalRequest;
use crate::function_tool::FunctionCallError;
use crate::session::step_context::StepContext;
use crate::session::turn_context::TurnEnvironment;
use crate::shell::ShellType;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::events::ToolEmitter;
use crate::tools::events::ToolEventCtx;
use crate::tools::handlers::apply_granted_turn_permissions;
use crate::tools::handlers::apply_patch::intercept_apply_patch;
use crate::tools::handlers::implicit_granted_permissions;
use crate::tools::handlers::normalize_and_validate_additional_permissions;
use crate::tools::handlers::parse_arguments;
use crate::tools::orchestrator::ToolOrchestrator;
use crate::tools::runtimes::shell::ShellRequest;
use crate::tools::runtimes::shell::ShellRuntime;
use crate::tools::runtimes::shell::ShellRuntimeBackend;
use crate::tools::sandboxing::ToolCtx;
use codex_core_plugins::strip_output_env;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::protocol::ExecCommandSource;
use codex_tools::ToolName;
use codex_utils_path_uri::PathUri;
mod shell_command;
pub use shell_command::ShellCommandHandler;
pub(crate) use shell_command::ShellCommandHandlerOptions;
fn shell_command_payload_command(payload: &ToolPayload) -> Option<String> {
let ToolPayload::Function { arguments } = payload else {
return None;
};
parse_arguments::<ShellCommandToolCallParams>(arguments)
.ok()
.map(|params| params.command)
}
struct RunExecLikeArgs {
tool_name: ToolName,
exec_params: ExecParams,
cancellation_token: CancellationToken,
hook_command: String,
shell_type: Option<ShellType>,
additional_permissions: Option<AdditionalPermissionProfile>,
prefix_rule: Option<Vec<String>>,
session: Arc<crate::session::session::Session>,
step_context: Arc<StepContext>,
turn_environment: TurnEnvironment,
tracker: crate::tools::context::SharedTurnDiffTracker,
call_id: String,
shell_runtime_backend: ShellRuntimeBackend,
}
async fn run_exec_like(args: RunExecLikeArgs) -> Result<FunctionToolOutput, FunctionCallError> {
let RunExecLikeArgs {
tool_name,
exec_params,
cancellation_token,
hook_command,
shell_type,
additional_permissions,
prefix_rule,
session,
step_context,
turn_environment,
tracker,
call_id,
shell_runtime_backend,
} = args;
let turn = Arc::clone(&step_context.turn);
let fs = turn_environment.environment.get_filesystem();
let mut explicit_env_overrides = turn_environment.shell_environment_policy().r#set.clone();
let mut env = exec_params.env.clone();
strip_output_env(&mut env);
strip_output_env(&mut explicit_env_overrides);
let exec_permission_approvals_enabled =
session.features().enabled(Feature::ExecPermissionApprovals);
let requested_additional_permissions = additional_permissions.clone();
let effective_additional_permissions = apply_granted_turn_permissions(
session.as_ref(),
&turn_environment.selection.environment_id,
exec_params.cwd.as_path(),
exec_params.sandbox_permissions,
additional_permissions,
)
.await;
let additional_permissions_allowed = exec_permission_approvals_enabled
|| (session.features().enabled(Feature::RequestPermissionsTool)
&& effective_additional_permissions.permissions_preapproved);
let normalized_additional_permissions = implicit_granted_permissions(
exec_params.sandbox_permissions,
requested_additional_permissions.as_ref(),
&effective_additional_permissions,
)
.map_or_else(
|| {
normalize_and_validate_additional_permissions(
additional_permissions_allowed,
turn.approval_policy(),
effective_additional_permissions.sandbox_permissions,
effective_additional_permissions.additional_permissions,
effective_additional_permissions.permissions_preapproved,
&exec_params.cwd,
)
},
|permissions| Ok(Some(permissions)),
)
.map_err(FunctionCallError::RespondToModel)?;
// Approval policy guard for explicit escalation in non-OnRequest modes.
// Sticky turn permissions have already been approved, so they should
// continue through the normal exec approval flow for the command.
if effective_additional_permissions
.sandbox_permissions
.requests_sandbox_override()
&& !effective_additional_permissions.permissions_preapproved
&& !matches!(
turn.approval_policy(),
codex_protocol::protocol::AskForApproval::OnRequest
)
{
let approval_policy = turn.approval_policy();
return Err(FunctionCallError::RespondToModel(format!(
"approval policy is {approval_policy:?}; reject command — you should not ask for escalated permissions if the approval policy is {approval_policy:?}"
)));
}
// Intercept apply_patch if present.
let apply_patch_cwd = PathUri::from_abs_path(&exec_params.cwd);
if let Some(output) = intercept_apply_patch(
&exec_params.command,
&apply_patch_cwd,
fs.as_ref(),
turn_environment.clone(),
session.clone(),
Arc::clone(&step_context),
Some(&tracker),
&call_id,
tool_name.name.as_str(),
)
.await?
{
return Ok(output);
}
let source = ExecCommandSource::Agent;
let plugin_attribution =
turn.plugin_attribution_for_command(&exec_params.command, &exec_params.cwd);
let emitter = ToolEmitter::shell(
exec_params.command.clone(),
exec_params.cwd.clone(),
source,
plugin_attribution,
);
let event_ctx = ToolEventCtx::new(
session.as_ref(),
turn.as_ref(),
&call_id,
/*turn_diff_tracker*/ None,
);
emitter.begin(event_ctx).await;
let exec_approval_requirement = session
.services
.exec_policy
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &exec_params.command,
approval_policy: turn.approval_policy(),
permission_profile: turn_environment.permission_profile().clone(),
environment_policy: turn_environment.config().exec_policy.as_ref(),
windows_sandbox_level: turn.windows_sandbox_level,
sandbox_permissions: if effective_additional_permissions.permissions_preapproved {
codex_protocol::models::SandboxPermissions::UseDefault
} else {
effective_additional_permissions.sandbox_permissions
},
prefix_rule,
allow_prefix_rules: turn.allow_prefix_rules(),
})
.await;
let req = ShellRequest {
command: exec_params.command.clone(),
turn_environment: turn_environment.clone(),
shell_type,
hook_command,
cwd: exec_params.cwd.clone(),
timeout_ms: exec_params.expiration.timeout_ms(),
cancellation_token,
env,
explicit_env_overrides,
network: exec_params.network.clone(),
sandbox_permissions: effective_additional_permissions.sandbox_permissions,
additional_permissions: normalized_additional_permissions,
#[cfg(unix)]
additional_permissions_preapproved: effective_additional_permissions
.permissions_preapproved,
justification: exec_params.justification.clone(),
exec_approval_requirement,
};
let mut orchestrator = ToolOrchestrator::new();
let mut runtime = ShellRuntime::for_shell_command(shell_runtime_backend);
let tool_ctx = ToolCtx {
session: session.clone(),
step_context,
call_id: call_id.clone(),
tool_name,
};
let out = orchestrator
.run(&mut runtime, &req, &tool_ctx, &turn, turn.approval_policy())
.await
.map(|result| result.output);
let event_ctx = ToolEventCtx::new(
session.as_ref(),
turn.as_ref(),
&call_id,
/*turn_diff_tracker*/ None,
);
let post_tool_use_response = out
.as_ref()
.ok()
.map(|output| {
crate::tools::format_exec_output_str(output, turn.model_info.truncation_policy.into())
})
.map(JsonValue::String);
let content = emitter
.finish(event_ctx, out, /*applied_patch_delta*/ None)
.await?;
Ok(FunctionToolOutput {
body: vec![
codex_protocol::models::FunctionCallOutputContentItem::InputText { text: content },
],
success: Some(true),
post_tool_use_response,
})
}
#[cfg(test)]
#[path = "shell_tests.rs"]
mod tests;
@@ -1,306 +0,0 @@
use codex_exec_server::LOCAL_ENVIRONMENT_ID;
use codex_protocol::models::ShellCommandToolCallParams;
use codex_tools::ShellCommandBackendConfig;
use codex_tools::ToolName;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use crate::exec::ExecCapturePolicy;
use crate::exec::ExecParams;
use crate::exec_env::create_env;
use crate::exec_env::inject_apply_patch_env;
use crate::exec_env::inject_permission_profile_env;
use crate::exec_env::inject_session_id_env;
use crate::function_tool::FunctionCallError;
use crate::maybe_emit_implicit_skill_invocation;
use crate::session::turn_context::TurnContext;
use crate::session::turn_context::TurnEnvironment;
use crate::shell::Shell;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::parse_arguments_with_base_path;
use crate::tools::handlers::resolve_sandbox_permissions;
use crate::tools::handlers::resolve_workdir_base_path;
use crate::tools::handlers::rewrite_function_string_argument;
use crate::tools::handlers::updated_hook_command;
use crate::tools::hook_names::HookToolName;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::PostToolUsePayload;
use crate::tools::registry::PreToolUsePayload;
use crate::tools::registry::ToolExecutor;
use crate::tools::runtimes::shell::ShellRuntimeBackend;
use codex_tools::ToolSpec;
use super::super::shell_spec::CommandToolOptions;
use super::super::shell_spec::create_shell_command_tool;
use super::RunExecLikeArgs;
use super::run_exec_like;
use super::shell_command_payload_command;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ShellCommandBackend {
Classic,
ZshFork,
}
pub struct ShellCommandHandler {
backend: ShellCommandBackend,
options: ShellCommandHandlerOptions,
}
#[derive(Clone, Copy)]
pub(crate) struct ShellCommandHandlerOptions {
pub(crate) backend_config: ShellCommandBackendConfig,
pub(crate) allow_login_shell: bool,
pub(crate) exec_permission_approvals_enabled: bool,
}
impl ShellCommandHandler {
pub(crate) fn new(options: ShellCommandHandlerOptions) -> Self {
let backend = match options.backend_config {
ShellCommandBackendConfig::Classic => ShellCommandBackend::Classic,
ShellCommandBackendConfig::ZshFork => ShellCommandBackend::ZshFork,
};
Self { backend, options }
}
fn shell_runtime_backend(&self) -> ShellRuntimeBackend {
match self.backend {
ShellCommandBackend::Classic => ShellRuntimeBackend::ShellCommandClassic,
ShellCommandBackend::ZshFork => ShellRuntimeBackend::ShellCommandZshFork,
}
}
pub(super) fn resolve_use_login_shell(
login: Option<bool>,
allow_login_shell: bool,
) -> Result<bool, FunctionCallError> {
if !allow_login_shell && login == Some(true) {
return Err(FunctionCallError::RespondToModel(
"login shell is disabled by config; omit `login` or set it to false.".to_string(),
));
}
Ok(login.unwrap_or(allow_login_shell))
}
pub(super) fn base_command(shell: &Shell, command: &str, use_login_shell: bool) -> Vec<String> {
shell.derive_exec_args(command, use_login_shell)
}
pub(super) fn to_exec_params(
params: &ShellCommandToolCallParams,
session: &crate::session::session::Session,
turn_context: &TurnContext,
turn_environment: &TurnEnvironment,
cwd: AbsolutePathBuf,
) -> Result<ExecParams, FunctionCallError> {
let session_shell = session.user_shell();
let shell = turn_environment
.shell
.as_ref()
.unwrap_or(session_shell.as_ref());
let use_login_shell = Self::resolve_use_login_shell(
params.login,
turn_environment.config().allow_login_shell,
)?;
let command = Self::base_command(shell, &params.command, use_login_shell);
let mut env = create_env(
turn_environment.shell_environment_policy(),
Some(session.thread_id),
);
inject_session_id_env(&mut env, session.session_id());
inject_apply_patch_env(&mut env, &turn_context.config.features);
let active_permission_profile = turn_environment.active_permission_profile();
inject_permission_profile_env(&mut env, active_permission_profile.as_ref());
let sandbox_permissions = resolve_sandbox_permissions(
params.sandbox_permissions,
params.justification.as_deref(),
)?;
Ok(ExecParams {
command,
cwd,
expiration: params.timeout_ms.into(),
capture_policy: ExecCapturePolicy::ShellTool,
env,
network: turn_context.network.clone(),
network_environment_id: Some(turn_environment.selection.environment_id.clone()),
sandbox_permissions,
windows_sandbox_level: turn_context.windows_sandbox_level,
windows_sandbox_private_desktop: turn_context
.config
.permissions
.windows_sandbox_private_desktop,
justification: params.justification.clone(),
arg0: None,
})
}
}
impl From<ShellCommandBackendConfig> for ShellCommandHandler {
fn from(backend_config: ShellCommandBackendConfig) -> Self {
Self::new(ShellCommandHandlerOptions {
backend_config,
allow_login_shell: false,
exec_permission_approvals_enabled: false,
})
}
}
impl ToolExecutor<ToolInvocation> for ShellCommandHandler {
fn tool_name(&self) -> ToolName {
ToolName::plain("shell_command")
}
fn spec(&self) -> ToolSpec {
create_shell_command_tool(CommandToolOptions {
allow_login_shell: self.options.allow_login_shell,
exec_permission_approvals_enabled: self.options.exec_permission_approvals_enabled,
})
}
fn supports_parallel_tool_calls(&self) -> bool {
true
}
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(self.handle_call(invocation))
}
}
impl ShellCommandHandler {
async fn handle_call(
&self,
invocation: ToolInvocation,
) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
let ToolInvocation {
session,
turn,
step_context,
cancellation_token,
tracker,
call_id,
payload,
..
} = invocation;
let tool_name = self.tool_name();
let ToolPayload::Function { arguments } = payload else {
return Err(FunctionCallError::RespondToModel(format!(
"unsupported payload for shell_command handler: {tool_name}"
)));
};
let Some(turn_environment) = step_context.environments.primary().cloned() else {
return Err(FunctionCallError::RespondToModel(
"shell is unavailable in this session".to_string(),
));
};
let environment_cwd = turn_environment.cwd().to_abs_path().map_err(|err| {
FunctionCallError::RespondToModel(format!(
"shell_command cwd `{}` is not native to the Codex host: {err}",
turn_environment.cwd()
))
})?;
let cwd = resolve_workdir_base_path(&arguments, &environment_cwd)?;
let params: ShellCommandToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?;
maybe_emit_implicit_skill_invocation(
session.as_ref(),
turn.as_ref(),
&params.command,
&PathUri::from_abs_path(&cwd),
Some(&cwd),
LOCAL_ENVIRONMENT_ID,
)
.await;
let prefix_rule = params.prefix_rule.clone();
let exec_params = Self::to_exec_params(
&params,
session.as_ref(),
turn.as_ref(),
&turn_environment,
cwd,
)?;
let shell_type = Some(
turn_environment
.shell
.as_ref()
.map_or_else(|| session.user_shell().shell_type, |shell| shell.shell_type),
);
run_exec_like(RunExecLikeArgs {
tool_name,
exec_params,
cancellation_token,
hook_command: params.command,
shell_type,
additional_permissions: params.additional_permissions.clone(),
prefix_rule,
session,
step_context,
turn_environment,
tracker,
call_id,
shell_runtime_backend: self.shell_runtime_backend(),
})
.await
.map(boxed_tool_output)
}
}
impl CoreToolRuntime for ShellCommandHandler {
fn matches_kind(&self, payload: &ToolPayload) -> bool {
matches!(payload, ToolPayload::Function { .. })
}
fn waits_for_runtime_cancellation(&self) -> bool {
true
}
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
shell_command_payload_command(&invocation.payload).map(|command| PreToolUsePayload {
tool_name: HookToolName::bash(),
tool_input: serde_json::json!({ "command": command }),
})
}
fn with_updated_hook_input(
&self,
mut invocation: ToolInvocation,
updated_input: serde_json::Value,
) -> Result<ToolInvocation, FunctionCallError> {
let ToolPayload::Function { arguments } = invocation.payload else {
return Err(FunctionCallError::RespondToModel(
"hook input rewrite received unsupported shell_command payload".to_string(),
));
};
invocation.payload = ToolPayload::Function {
arguments: rewrite_function_string_argument(
&arguments,
"shell_command",
"command",
updated_hook_command(&updated_input)?,
)?,
};
Ok(invocation)
}
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
result: &dyn crate::tools::context::ToolOutput,
) -> Option<PostToolUsePayload> {
let tool_response =
result.post_tool_use_response(&invocation.call_id, &invocation.payload)?;
let command = shell_command_payload_command(&invocation.payload)?;
Some(PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: invocation.call_id.clone(),
tool_input: serde_json::json!({ "command": command }),
tool_response,
})
}
}
@@ -154,76 +154,6 @@ pub fn create_write_stdin_tool() -> ToolSpec {
})
}
pub fn create_shell_command_tool(options: CommandToolOptions) -> ToolSpec {
let mut properties = BTreeMap::from([
(
"command".to_string(),
JsonSchema::string(Some(
"Shell script to run in the user's default shell.".to_string(),
)),
),
(
"workdir".to_string(),
JsonSchema::string(Some(
"Working directory for the command. Defaults to the turn cwd.".to_string(),
)),
),
(
"timeout_ms".to_string(),
JsonSchema::number(Some(
"Maximum command runtime. Defaults to 10000 ms.".to_string(),
)),
),
]);
if options.allow_login_shell {
properties.insert(
"login".to_string(),
JsonSchema::boolean(Some(
"True runs with login shell semantics; false disables them. Defaults to true."
.to_string(),
)),
);
}
properties.extend(create_approval_parameters(
options.exec_permission_approvals_enabled,
));
let description = if cfg!(windows) {
format!(
r#"Runs a Powershell command (Windows) and returns its output.
Examples of valid command strings:
- ls -a (show hidden): "Get-ChildItem -Force"
- recursive find by name: "Get-ChildItem -Recurse -Filter *.py"
- recursive grep: "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"
- ps aux | grep python: "Get-Process | Where-Object {{ $_.ProcessName -like '*python*' }}"
- setting an env var: "$env:FOO='bar'; echo $env:FOO"
- running an inline Python script: "@'\\nprint('Hello, world!')\\n'@ | python -"
{}"#,
windows_shell_guidance()
)
} else {
r#"Runs a shell command and returns its output.
- Always set the `workdir` param when using the shell_command function. Do not use `cd` unless absolutely necessary."#
.to_string()
};
ToolSpec::Function(ResponsesApiTool {
name: "shell_command".to_string(),
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::object(
properties,
Some(vec!["command".to_string()]),
Some(false.into()),
),
output_schema: None,
})
}
pub fn create_request_permissions_tool(description: String) -> ToolSpec {
let properties = BTreeMap::from([
(
@@ -201,77 +201,3 @@ fn request_permissions_tool_includes_full_permission_schema() {
})
);
}
#[test]
fn shell_command_tool_matches_expected_spec() {
let tool = create_shell_command_tool(CommandToolOptions {
allow_login_shell: true,
exec_permission_approvals_enabled: false,
});
let description = if cfg!(windows) {
r#"Runs a Powershell command (Windows) and returns its output.
Examples of valid command strings:
- ls -a (show hidden): "Get-ChildItem -Force"
- recursive find by name: "Get-ChildItem -Recurse -Filter *.py"
- recursive grep: "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"
- ps aux | grep python: "Get-Process | Where-Object { $_.ProcessName -like '*python*' }"
- setting an env var: "$env:FOO='bar'; echo $env:FOO"
- running an inline Python script: "@'\\nprint('Hello, world!')\\n'@ | python -""#
.to_string()
+ &windows_shell_guidance_description()
} else {
r#"Runs a shell command and returns its output.
- Always set the `workdir` param when using the shell_command function. Do not use `cd` unless absolutely necessary."#
.to_string()
};
let mut properties = BTreeMap::from([
(
"command".to_string(),
JsonSchema::string(Some(
"Shell script to run in the user's default shell.".to_string(),
)),
),
(
"workdir".to_string(),
JsonSchema::string(Some(
"Working directory for the command. Defaults to the turn cwd.".to_string(),
)),
),
(
"timeout_ms".to_string(),
JsonSchema::number(Some(
"Maximum command runtime. Defaults to 10000 ms.".to_string(),
)),
),
(
"login".to_string(),
JsonSchema::boolean(Some(
"True runs with login shell semantics; false disables them. Defaults to true."
.to_string(),
)),
),
]);
properties.extend(create_approval_parameters(
/*exec_permission_approvals_enabled*/ false,
));
assert_eq!(
tool,
ToolSpec::Function(ResponsesApiTool {
name: "shell_command".to_string(),
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::object(
properties,
Some(vec!["command".to_string()]),
Some(false.into())
),
output_schema: None,
})
);
}
@@ -1,336 +0,0 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use codex_protocol::config_types::EnvironmentVariablePattern;
use codex_protocol::config_types::ShellEnvironmentPolicy;
use codex_protocol::config_types::ShellEnvironmentPolicyInherit;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::ShellCommandToolCallParams;
use pretty_assertions::assert_eq;
use crate::config::PermissionProfileSnapshot;
use crate::environment_selection::EnvironmentConfigOrigin;
use crate::exec_env::CODEX_PERMISSION_PROFILE_ENV_VAR;
use crate::exec_env::create_env;
use crate::exec_env::inject_permission_profile_env;
use crate::exec_env::inject_session_id_env;
use crate::sandboxing::SandboxPermissions;
use crate::session::step_context::StepContext;
use crate::session::tests::make_session_and_context;
use crate::session::turn_context::TurnEnvironment;
use crate::shell::Shell;
use crate::shell::ShellType;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolCallSource;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::ShellCommandHandler;
use crate::tools::hook_names::HookToolName;
use crate::tools::registry::CoreToolRuntime;
use crate::turn_diff_tracker::TurnDiffTracker;
use codex_protocol::protocol::EnvironmentConfig;
use codex_protocol::protocol::EnvironmentConfigState;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_utils_path_uri::PathUri;
use serde_json::json;
use tokio::sync::Mutex;
#[tokio::test]
async fn shell_command_handler_to_exec_params_uses_selected_environment() {
let (session, mut turn_context) = make_session_and_context().await;
let permission_profile = turn_context.config.permissions.permission_profile().clone();
let config = Arc::make_mut(&mut turn_context.config);
config.permissions.shell_environment_policy.r#set = HashMap::from([
("KEEP".to_string(), "from-thread".to_string()),
("DROP".to_string(), "from-thread".to_string()),
]);
config.permissions.shell_environment_policy.include_only =
vec![EnvironmentVariablePattern::new_case_insensitive("DROP")];
config
.permissions
.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active(
permission_profile.clone(),
ActivePermissionProfile::new("thread-profile"),
))
.expect("set active permission profile");
let command = "echo hello".to_string();
let workdir = Some("subdir".to_string());
let login = None;
let timeout_ms = Some(1234);
let sandbox_permissions = SandboxPermissions::RequireEscalated;
let justification = Some("because tests".to_string());
let selected_shell = Shell {
shell_type: ShellType::Bash,
shell_path: PathBuf::from("/selected/bin/bash"),
};
let expected_command = selected_shell.derive_exec_args(&command, /*use_login_shell*/ true);
let selected_cwd = turn_context.config.cwd.join("selected-environment");
let expected_cwd = selected_cwd.join("subdir");
let active_permission_profile = ActivePermissionProfile::new("selected-profile");
let selected_shell_environment_policy = ShellEnvironmentPolicy {
inherit: ShellEnvironmentPolicyInherit::None,
include_only: vec![EnvironmentVariablePattern::new_case_insensitive("KEEP")],
r#set: turn_context
.config
.permissions
.shell_environment_policy
.r#set
.clone(),
..Default::default()
};
let selected_environment = TurnEnvironment::new(
TurnEnvironmentSelection {
environment_id: "selected-environment".to_string(),
cwd: PathUri::from_abs_path(&selected_cwd),
workspace_roots: Vec::new(),
config: EnvironmentConfigState::Ready(EnvironmentConfig {
allow_login_shell: true,
permission_profile: PermissionProfileSnapshot::active(
permission_profile,
active_permission_profile.clone(),
),
shell_environment_policy: selected_shell_environment_policy.clone(),
exec_policy: None,
mcp_policy: None,
network_policy: None,
selected_capability_roots: Vec::new(),
}),
},
EnvironmentConfigOrigin::Thread,
Arc::clone(
&turn_context
.environments
.primary()
.expect("primary environment")
.environment,
),
Some(selected_shell),
);
let mut expected_env = create_env(&selected_shell_environment_policy, Some(session.thread_id));
inject_session_id_env(&mut expected_env, session.session_id());
inject_permission_profile_env(&mut expected_env, Some(&active_permission_profile));
let params = ShellCommandToolCallParams {
command,
workdir,
login,
timeout_ms,
sandbox_permissions: Some(sandbox_permissions),
additional_permissions: None,
prefix_rule: None,
justification: justification.clone(),
};
let exec_params = ShellCommandHandler::to_exec_params(
&params,
&session,
&turn_context,
&selected_environment,
expected_cwd.clone(),
)
.expect("login shells should be allowed");
// ExecParams cannot derive Eq due to the CancellationToken field, so we manually compare the fields.
assert_eq!(exec_params.command, expected_command);
assert_eq!(exec_params.cwd, expected_cwd);
assert_eq!(exec_params.env, expected_env);
assert_eq!(
exec_params.env.get(CODEX_PERMISSION_PROFILE_ENV_VAR),
Some(&active_permission_profile.id)
);
assert_eq!(exec_params.network, turn_context.network);
assert_eq!(
exec_params.network_environment_id.as_deref(),
Some("selected-environment")
);
assert_eq!(exec_params.expiration.timeout_ms(), timeout_ms);
assert_eq!(exec_params.sandbox_permissions, sandbox_permissions);
assert_eq!(exec_params.justification, justification);
assert_eq!(exec_params.arg0, None);
}
#[test]
fn shell_command_handler_respects_explicit_login_flag() {
let shell = Shell {
shell_type: ShellType::Bash,
shell_path: PathBuf::from("/bin/bash"),
};
let login_command = ShellCommandHandler::base_command(
&shell,
"echo login shell",
/*use_login_shell*/ true,
);
assert_eq!(
login_command,
shell.derive_exec_args("echo login shell", /*use_login_shell*/ true)
);
let non_login_command = ShellCommandHandler::base_command(
&shell,
"echo non login shell",
/*use_login_shell*/ false,
);
assert_eq!(
non_login_command,
shell.derive_exec_args("echo non login shell", /*use_login_shell*/ false)
);
}
#[tokio::test]
async fn shell_command_handler_defaults_to_non_login_when_disallowed() {
let (session, turn_context) = make_session_and_context().await;
let mut turn_environment = turn_context
.environments
.primary()
.expect("primary environment")
.clone();
turn_environment.config_mut().allow_login_shell = false;
let cwd = turn_environment
.cwd()
.to_abs_path()
.expect("native environment cwd");
let params = ShellCommandToolCallParams {
command: "echo hello".to_string(),
workdir: None,
login: None,
timeout_ms: None,
sandbox_permissions: None,
additional_permissions: None,
prefix_rule: None,
justification: None,
};
let exec_params = ShellCommandHandler::to_exec_params(
&params,
&session,
&turn_context,
&turn_environment,
cwd,
)
.expect("non-login shells should still be allowed");
assert_eq!(
exec_params.command,
session
.user_shell()
.derive_exec_args("echo hello", /*use_login_shell*/ false)
);
}
#[tokio::test]
async fn shell_command_handler_rejects_justification_without_sandbox_permissions() {
let (session, turn_context) = make_session_and_context().await;
let turn_environment = turn_context
.environments
.primary()
.expect("primary environment");
let cwd = turn_environment
.cwd()
.to_abs_path()
.expect("native environment cwd");
let params = ShellCommandToolCallParams {
command: "echo hello".to_string(),
workdir: None,
login: None,
timeout_ms: None,
sandbox_permissions: None,
additional_permissions: None,
prefix_rule: None,
justification: Some("Allow this command".to_string()),
};
let err = ShellCommandHandler::to_exec_params(
&params,
&session,
&turn_context,
turn_environment,
cwd,
)
.expect_err("justification without sandbox permissions should be rejected");
assert!(
err.to_string()
.contains("`justification` requires an explicit `sandbox_permissions`"),
"unexpected error: {err}"
);
}
#[test]
fn shell_command_handler_rejects_login_when_disallowed() {
let err =
ShellCommandHandler::resolve_use_login_shell(Some(true), /*allow_login_shell*/ false)
.expect_err("explicit login should be rejected");
assert!(
err.to_string()
.contains("login shell is disabled by config"),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn shell_command_pre_tool_use_payload_uses_raw_command() {
let payload = ToolPayload::Function {
arguments: json!({ "command": "printf shell command" }).to_string(),
};
let (session, turn) = make_session_and_context().await;
let turn = Arc::new(turn);
let handler = ShellCommandHandler::from(codex_tools::ShellCommandBackendConfig::Classic);
assert_eq!(
handler.pre_tool_use_payload(&ToolInvocation {
session: session.into(),
step_context: StepContext::for_test(Arc::clone(&turn)),
turn,
cancellation_token: tokio_util::sync::CancellationToken::new(),
tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
call_id: "call-42".to_string(),
tool_name: codex_tools::ToolName::plain("shell_command"),
source: crate::tools::context::ToolCallSource::Direct,
payload,
}),
Some(crate::tools::registry::PreToolUsePayload {
tool_name: HookToolName::bash(),
tool_input: json!({ "command": "printf shell command" }),
})
);
}
#[tokio::test]
async fn build_post_tool_use_payload_uses_tool_output_wire_value() {
let payload = ToolPayload::Function {
arguments: json!({ "command": "printf shell command" }).to_string(),
};
let output = FunctionToolOutput {
body: vec![],
success: Some(true),
post_tool_use_response: Some(json!("shell output")),
};
let handler = ShellCommandHandler::from(codex_tools::ShellCommandBackendConfig::Classic);
let (session, turn) = make_session_and_context().await;
let turn = Arc::new(turn);
let invocation = ToolInvocation {
session: session.into(),
step_context: StepContext::for_test(Arc::clone(&turn)),
turn,
cancellation_token: tokio_util::sync::CancellationToken::new(),
tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
call_id: "call-42".to_string(),
tool_name: codex_tools::ToolName::plain("shell_command"),
source: ToolCallSource::Direct,
payload,
};
assert_eq!(
handler.post_tool_use_payload(&invocation, &output),
Some(crate::tools::registry::PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: "call-42".to_string(),
tool_input: json!({ "command": "printf shell command" }),
tool_response: json!("shell output"),
})
);
}
+6 -53
View File
@@ -45,16 +45,9 @@ use uuid::Uuid;
const ABANDONED_NETWORK_APPROVAL_MESSAGE: &str =
"network approval was cancelled before a decision was returned";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum NetworkApprovalMode {
Immediate,
Deferred,
}
#[derive(Clone, Debug)]
pub(crate) struct NetworkApprovalSpec {
pub network: Option<NetworkProxy>,
pub mode: NetworkApprovalMode,
pub trigger: GuardianNetworkAccessTrigger,
pub command: String,
pub environment_id: String,
@@ -97,16 +90,11 @@ impl DeferredNetworkApproval {
#[derive(Debug)]
pub(crate) struct ActiveNetworkApproval {
registration_id: Option<String>,
mode: NetworkApprovalMode,
cancellation_token: CancellationToken,
execution_proxy: NetworkProxy,
}
impl ActiveNetworkApproval {
pub(crate) fn mode(&self) -> NetworkApprovalMode {
self.mode
}
pub(crate) fn cancellation_token(&self) -> CancellationToken {
self.cancellation_token.clone()
}
@@ -118,21 +106,15 @@ impl ActiveNetworkApproval {
pub(crate) fn into_deferred(self) -> Option<DeferredNetworkApproval> {
let ActiveNetworkApproval {
registration_id,
mode,
cancellation_token,
execution_proxy,
} = self;
match (mode, registration_id) {
(NetworkApprovalMode::Deferred, Some(registration_id)) => {
Some(DeferredNetworkApproval {
registration_id,
cancellation_token,
finish_outcome: Arc::new(OnceCell::new()),
_execution_proxy: Some(execution_proxy),
})
}
_ => None,
}
registration_id.map(|registration_id| DeferredNetworkApproval {
registration_id,
cancellation_token,
finish_outcome: Arc::new(OnceCell::new()),
_execution_proxy: Some(execution_proxy),
})
}
}
@@ -576,18 +558,6 @@ impl NetworkApprovalService {
self.remove_call(registration_id).await
}
async fn finish_call(
&self,
registration_id: &str,
cancellation_token: &CancellationToken,
) -> Result<(), ToolError> {
let outcome = self
.finish_call_outcome(registration_id)
.await
.or_else(|| abandoned_network_approval_outcome(cancellation_token));
network_approval_outcome_to_result(outcome)
}
pub(crate) async fn record_blocked_request(&self, blocked: BlockedRequest) {
let Some(message) = denied_network_policy_message(&blocked) else {
return;
@@ -1055,7 +1025,6 @@ pub(crate) async fn begin_network_approval(
) -> Result<Option<ActiveNetworkApproval>, ToolError> {
let NetworkApprovalSpec {
network,
mode,
trigger,
command,
environment_id,
@@ -1097,27 +1066,11 @@ pub(crate) async fn begin_network_approval(
Ok(Some(ActiveNetworkApproval {
registration_id: Some(registration_id),
mode,
cancellation_token,
execution_proxy,
}))
}
pub(crate) async fn finish_immediate_network_approval(
session: &Session,
active: ActiveNetworkApproval,
) -> Result<(), ToolError> {
let Some(registration_id) = active.registration_id.as_deref() else {
return Ok(());
};
session
.services
.network_approval
.finish_call(registration_id, &active.cancellation_token)
.await
}
pub(crate) async fn finish_deferred_network_approval(
session: &Session,
deferred: Option<DeferredNetworkApproval>,
@@ -480,7 +480,7 @@ async fn register_call_with_default_shell_trigger(
turn_id: "turn-1".to_string(),
trigger: GuardianNetworkAccessTrigger {
call_id: "call-1".to_string(),
tool_name: "shell_command".to_string(),
tool_name: "exec_command".to_string(),
command: vec!["curl".to_string(), "https://example.com".to_string()],
cwd: PathUri::from_abs_path(&test_path_buf("/tmp").abs()),
sandbox_permissions: SandboxPermissions::UseDefault,
@@ -502,7 +502,7 @@ async fn active_call_preserves_triggering_command_context() {
let service = NetworkApprovalService::default();
let expected = GuardianNetworkAccessTrigger {
call_id: "call-1".to_string(),
tool_name: "shell_command".to_string(),
tool_name: "exec_command".to_string(),
command: vec!["curl".to_string(), "https://example.com".to_string()],
cwd: PathUri::parse("file:///C:/repo").expect("valid Windows path URI"),
sandbox_permissions: SandboxPermissions::UseDefault,
@@ -669,42 +669,6 @@ fn approval_denial_messages_are_bounded_for_model_context() {
assert!(message.contains("tokens truncated"));
}
#[tokio::test]
async fn finish_call_returns_denial_and_unregisters_active_call() {
let service = NetworkApprovalService::default();
let cancellation_token =
register_call_with_default_shell_trigger(&service, "registration-1").await;
service.record_call_outcome("registration-1", "network denied".to_string());
let err = service
.finish_call("registration-1", &cancellation_token)
.await
.expect_err("denial should be returned");
assert!(matches!(err, ToolError::Rejected(message) if message == "network denied"));
assert!(service.resolve_single_active_call().await.is_none());
assert_eq!(service.take_call_outcome("registration-1").await, None);
}
#[tokio::test]
async fn finish_call_reports_abandoned_network_approval() {
let service = NetworkApprovalService::default();
let cancellation_token =
register_call_with_default_shell_trigger(&service, "registration-1").await;
cancellation_token.cancel();
let err = service
.finish_call("registration-1", &cancellation_token)
.await
.expect_err("abandoned approval should be returned");
assert!(matches!(
err,
ToolError::Rejected(message) if message == ABANDONED_NETWORK_APPROVAL_MESSAGE
));
}
#[tokio::test]
async fn deferred_finish_reuses_denial_result_after_first_consumer() {
let service = NetworkApprovalService::default();
+8 -22
View File
@@ -12,10 +12,8 @@ use crate::tools::approvals::ApprovalContext;
use crate::tools::flat_tool_name;
use crate::tools::network_approval::ActiveNetworkApproval;
use crate::tools::network_approval::DeferredNetworkApproval;
use crate::tools::network_approval::NetworkApprovalMode;
use crate::tools::network_approval::begin_network_approval;
use crate::tools::network_approval::finish_deferred_network_approval;
use crate::tools::network_approval::finish_immediate_network_approval;
use crate::tools::sandboxing::ExecApprovalRequirement;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::SandboxOverride;
@@ -108,28 +106,16 @@ impl ToolOrchestrator {
return (run_result, None);
};
match network_approval.mode() {
NetworkApprovalMode::Immediate => {
let finalize_result =
finish_immediate_network_approval(&tool_ctx.session, network_approval).await;
if let Err(err) = finalize_result {
return (Err(err), None);
}
(run_result, None)
}
NetworkApprovalMode::Deferred => {
let deferred = network_approval.into_deferred();
if run_result.is_err() {
let finalize_result =
finish_deferred_network_approval(&tool_ctx.session, deferred).await;
if let Err(err) = finalize_result {
return (Err(err), None);
}
return (run_result, None);
}
(run_result, deferred)
let deferred = network_approval.into_deferred();
if run_result.is_err() {
let finalize_result =
finish_deferred_network_approval(&tool_ctx.session, deferred).await;
if let Err(err) = finalize_result {
return (Err(err), None);
}
return (run_result, None);
}
(run_result, deferred)
}
pub async fn run<Rq, Out, T>(
+1 -1
View File
@@ -269,7 +269,7 @@ impl ToolCallRuntime {
if call.tool_name.is_default_namespace()
&& matches!(
call.tool_name.name.as_str(),
"shell_command" | "unified_exec"
"exec_command" | "unified_exec"
)
{
format!("Wall time: {secs:.1} seconds\naborted by user")
+3 -1
View File
@@ -350,7 +350,9 @@ impl ToolRegistry {
exposure: ToolExposure,
) -> bool {
let tool_name = runtime.tool_name().with_default_namespace();
if tool_name.is_default_namespace() && tool_name.name == "shell_command" {
if tool_name.is_default_namespace()
&& matches!(tool_name.name.as_str(), "exec_command" | "shell_command")
{
tracing::warn!(tool_name = %tool_name, "skipping external tool with reserved name");
if self.tools.contains_key(&tool_name) {
self.record_collision(tool_name);
+28 -26
View File
@@ -296,39 +296,41 @@ fn registry_preserves_external_winners_and_trusted_synthetic_order() {
}
#[test]
fn reserved_shell_command_rejects_external_runtimes_without_a_builtin() {
fn reserved_command_tools_reject_external_runtimes_without_a_builtin() {
let handler = |tool_name| Arc::new(TestHandler { tool_name }) as Arc<dyn CoreToolRuntime>;
let shell_command_name = codex_tools::ToolName::plain("shell_command");
let namespaced_shell_command_name =
codex_tools::ToolName::namespaced("client", "shell_command");
let mut registry = ToolRegistry::default();
assert!(!registry.register_external(handler(shell_command_name.clone())));
assert!(!registry.register_external_with_exposure(
handler(shell_command_name.clone()),
ToolExposure::Direct,
));
assert!(
!registry.register_external(handler(codex_tools::ToolName::namespaced(
DEFAULT_FUNCTION_NAMESPACE,
"shell_command",
)))
);
assert!(registry.tool(&shell_command_name).is_none());
assert_eq!(registry.first_collision(), None);
for reserved_name in ["exec_command", "shell_command"] {
let tool_name = codex_tools::ToolName::plain(reserved_name);
let namespaced_tool_name = codex_tools::ToolName::namespaced("client", reserved_name);
let namespaced_handler = handler(namespaced_shell_command_name.clone());
assert!(registry.register_external(Arc::clone(&namespaced_handler)));
assert!(
registry
.tool(&namespaced_shell_command_name)
.is_some_and(|runtime| Arc::ptr_eq(&runtime, &namespaced_handler))
);
assert!(!registry.register_external(handler(tool_name.clone())));
assert!(
!registry
.register_external_with_exposure(handler(tool_name.clone()), ToolExposure::Direct)
);
assert!(
!registry.register_external(handler(codex_tools::ToolName::namespaced(
DEFAULT_FUNCTION_NAMESPACE,
reserved_name,
)))
);
assert!(registry.tool(&tool_name).is_none());
assert_eq!(registry.first_collision(), None);
let namespaced_handler = handler(namespaced_tool_name.clone());
assert!(registry.register_external(Arc::clone(&namespaced_handler)));
assert!(
registry
.tool(&namespaced_tool_name)
.is_some_and(|runtime| Arc::ptr_eq(&runtime, &namespaced_handler))
);
}
}
#[test]
fn registry_records_reserved_shell_command_when_a_matching_tool_exists() {
let tool_name = codex_tools::ToolName::plain("shell_command");
fn registry_records_reserved_exec_command_when_a_matching_tool_exists() {
let tool_name = codex_tools::ToolName::plain("exec_command");
let trusted = Arc::new(TestHandler {
tool_name: tool_name.clone(),
}) as Arc<dyn CoreToolRuntime>;
+1 -1
View File
@@ -167,7 +167,7 @@ async fn parallel_support_does_not_match_namespaced_local_tool_names() -> anyhow
&turn.dynamic_tools,
);
let parallel_tool_name = ["exec_command", "shell_command"]
let parallel_tool_name = ["exec_command"]
.into_iter()
.find(|name| {
router.tool_supports_parallel(&ToolCall {
-37
View File
@@ -10,7 +10,6 @@ use crate::exec_env::CODEX_THREAD_ID_ENV_VAR;
use crate::sandboxing::SandboxPermissions;
use crate::shell::Shell;
use crate::shell::ShellType;
use crate::tools::sandboxing::ToolError;
use codex_apply_patch::CODEX_APPLY_PATCH_PRESERVE_LINE_ENDINGS_ENV_VAR;
use codex_core_plugins::PLUGIN_METRICS_OUTPUT_ENV_VAR;
#[cfg(unix)]
@@ -25,11 +24,8 @@ use codex_network_proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY;
pub(crate) use codex_network_proxy::is_managed_proxy_env_var;
pub(crate) use codex_network_proxy::strip_managed_proxy_env;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::shell_environment::is_non_inheritable_env_var;
use codex_sandboxing::SandboxCommand;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use std::collections::HashMap;
#[cfg(unix)]
use std::path::Path;
@@ -38,28 +34,6 @@ pub(crate) mod apply_patch;
pub(crate) mod shell;
pub(crate) mod unified_exec;
/// Shared helper to construct sandbox transform inputs from a tokenized command line and native
/// working directory. Validates that at least a program is present.
pub(crate) fn build_sandbox_command(
command: &[String],
cwd: &AbsolutePathBuf,
env: &HashMap<String, String>,
additional_permissions: Option<AdditionalPermissionProfile>,
) -> Result<SandboxCommand, ToolError> {
let (program, args) = command
.split_first()
.ok_or_else(|| ToolError::Rejected("command args are empty".to_string()))?;
let cwd = PathUri::from_abs_path(cwd);
Ok(SandboxCommand {
program: program.clone().into(),
args: args.to_vec(),
cwd,
env: env.clone(),
managed_network: None,
additional_permissions,
})
}
pub(crate) fn exec_env_for_sandbox_permissions(
env: &HashMap<String, String>,
sandbox_permissions: SandboxPermissions,
@@ -156,17 +130,6 @@ pub(crate) fn apply_package_path_prepend(
runtime_path_prepends.prepend(env, path_dir.as_path());
}
#[cfg(unix)]
pub(crate) fn prepend_zsh_fork_bin_to_path(
env: &mut HashMap<String, String>,
shell_zsh_path: &Path,
) -> Option<String> {
let zsh_bin_dir = shell_zsh_path
.parent()
.map(|path| path.to_string_lossy().to_string())?;
prepend_path_entry(env, &zsh_bin_dir)
}
#[cfg(unix)]
pub(crate) fn apply_zsh_fork_path_prepend(
env: &mut HashMap<String, String>,
+8 -26
View File
@@ -88,14 +88,14 @@ async fn explicit_escalation_prepares_exec_without_managed_network() -> anyhow::
let mut env = HashMap::from([("CUSTOM_ENV".to_string(), "kept".to_string())]);
proxy.apply_to_env(&mut env);
let command = vec!["/bin/echo".to_string(), "ok".to_string()];
let command = build_sandbox_command(
&command,
&command_cwd,
&exec_env_for_sandbox_permissions(&env, SandboxPermissions::RequireEscalated),
/*additional_permissions*/ None,
)
.expect("build sandbox command");
let command = codex_sandboxing::SandboxCommand {
program: "/bin/echo".into(),
args: vec!["ok".to_string()],
cwd: PathUri::from_abs_path(&command_cwd),
env: exec_env_for_sandbox_permissions(&env, SandboxPermissions::RequireEscalated),
managed_network: None,
additional_permissions: None,
};
assert_eq!(command.cwd, PathUri::from_abs_path(&command_cwd));
let sandbox_policy_cwd = PathUri::from_abs_path(&native_sandbox_policy_cwd);
let options = ExecOptions {
@@ -236,24 +236,6 @@ fn runtime_path_prepends_ignores_empty_path_entry() {
);
}
#[cfg(unix)]
#[test]
fn prepend_zsh_fork_bin_to_path_ignores_empty_parent() {
let mut env = HashMap::from([("PATH".to_string(), "/usr/bin:/bin".to_string())]);
let result = prepend_zsh_fork_bin_to_path(&mut env, PathBuf::from("zsh").as_path());
assert_eq!(
result, None,
"zsh fork helper should not report a PATH update for an empty parent"
);
assert_eq!(
env.get("PATH").map(String::as_str),
Some("/usr/bin:/bin"),
"zsh fork helper should leave PATH unchanged when the parent is empty"
);
}
#[cfg(unix)]
#[test]
fn apply_zsh_fork_path_prepend_uses_shell_parent() {
-331
View File
@@ -1,331 +0,0 @@
/*
Runtime: shell
Executes shell requests under the orchestrator: asks for approval when needed,
builds sandbox transform inputs, and runs them under the current SandboxAttempt.
*/
#[cfg(unix)]
pub(crate) mod unix_escalation;
pub(crate) mod zsh_fork_backend;
use crate::exec::ExecCapturePolicy;
use crate::guardian::GuardianNetworkAccessTrigger;
use crate::plugins::metrics::finish_and_track_measurements;
use crate::plugins::metrics::sidecar_for_command;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::SandboxPermissions;
use crate::sandboxing::execute_env;
use crate::session::turn_context::TurnEnvironment;
use crate::shell::ShellType;
use crate::tools::flat_tool_name;
use crate::tools::network_approval::NetworkApprovalMode;
use crate::tools::network_approval::NetworkApprovalSpec;
use crate::tools::runtimes::RuntimePathPrepends;
#[cfg(unix)]
use crate::tools::runtimes::apply_zsh_fork_path_prepend;
use crate::tools::runtimes::build_sandbox_command;
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::sandboxing::Approvable;
use crate::tools::sandboxing::ApprovalAction;
use crate::tools::sandboxing::ExecApprovalRequirement;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::Sandboxable;
use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::managed_network_for_sandbox_permissions;
use crate::tools::sandboxing::sandbox_permissions_preserving_denied_reads;
use codex_core_plugins::PluginMetricsSidecar;
use codex_network_proxy::NetworkProxy;
use codex_protocol::exec_output::ExecToolCallOutput;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_sandboxing::SandboxablePreference;
use codex_sandboxing::policy_transforms::merge_permission_profiles;
use codex_shell_command::powershell::prefix_powershell_script_with_utf8;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use std::collections::HashMap;
use tokio_util::sync::CancellationToken;
#[derive(Clone, Debug)]
pub struct ShellRequest {
pub command: Vec<String>,
pub turn_environment: TurnEnvironment,
pub shell_type: Option<ShellType>,
pub hook_command: String,
pub cwd: AbsolutePathBuf,
pub timeout_ms: Option<u64>,
pub cancellation_token: CancellationToken,
pub env: HashMap<String, String>,
pub explicit_env_overrides: HashMap<String, String>,
pub network: Option<NetworkProxy>,
pub sandbox_permissions: SandboxPermissions,
pub additional_permissions: Option<AdditionalPermissionProfile>,
#[cfg(unix)]
pub additional_permissions_preapproved: bool,
pub justification: Option<String>,
pub exec_approval_requirement: ExecApprovalRequirement,
}
/// Selects `ShellRuntime` behavior for different callers.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ShellRuntimeBackend {
/// Legacy backend for the `shell_command` tool.
///
/// Keeps `shell_command` on the standard shell runtime flow without the
/// zsh-fork shell-escalation adapter.
ShellCommandClassic,
/// zsh-fork backend for the `shell_command` tool.
///
/// On Unix, attempts to run via the zsh-fork + `codex-shell-escalation`
/// adapter, with fallback to the standard shell runtime flow if
/// prerequisites are not met.
ShellCommandZshFork,
}
pub struct ShellRuntime {
backend: ShellRuntimeBackend,
}
#[derive(serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)]
pub(crate) struct ApprovalKey {
pub(crate) environment_id: String,
pub(crate) command: Vec<String>,
pub(crate) cwd: PathUri,
pub(crate) sandbox_permissions: SandboxPermissions,
pub(crate) additional_permissions: Option<AdditionalPermissionProfile>,
}
impl ShellRuntime {
pub(crate) fn for_shell_command(backend: ShellRuntimeBackend) -> Self {
Self { backend }
}
fn stdout_stream(ctx: &ToolCtx) -> Option<crate::exec::StdoutStream> {
Some(crate::exec::StdoutStream {
sub_id: ctx.step_context.turn.sub_id.clone(),
call_id: ctx.call_id.clone(),
tx_event: ctx.session.get_tx_event(),
})
}
}
impl Sandboxable for ShellRuntime {
fn sandbox_preference(&self) -> SandboxablePreference {
SandboxablePreference::Auto
}
fn escalate_on_failure(&self) -> bool {
true
}
}
impl Approvable<ShellRequest> for ShellRuntime {
fn approval_action(
&self,
req: &ShellRequest,
call_id: &str,
) -> std::io::Result<ApprovalAction> {
Ok(ApprovalAction::Shell {
id: call_id.to_string(),
environment_id: req.turn_environment.selection.environment_id.clone(),
command: req.command.clone(),
hook_command: req.hook_command.clone(),
cwd: PathUri::from_abs_path(&req.cwd),
sandbox_permissions: req.sandbox_permissions,
additional_permissions: req.additional_permissions.clone(),
justification: req.justification.clone(),
proposed_execpolicy_amendment: req
.exec_approval_requirement
.proposed_execpolicy_amendment()
.cloned(),
})
}
fn exec_approval_requirement(&self, req: &ShellRequest) -> Option<ExecApprovalRequirement> {
Some(req.exec_approval_requirement.clone())
}
fn sandbox_permissions(&self, req: &ShellRequest) -> SandboxPermissions {
req.sandbox_permissions
}
}
impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
fn turn_environment<'a>(&self, req: &'a ShellRequest) -> &'a TurnEnvironment {
&req.turn_environment
}
fn network_approval_spec(
&self,
req: &ShellRequest,
ctx: &ToolCtx,
) -> Option<NetworkApprovalSpec> {
let file_system_sandbox_policy = req
.turn_environment
.permission_profile()
.file_system_sandbox_policy();
let sandbox_permissions = sandbox_permissions_preserving_denied_reads(
req.sandbox_permissions,
&file_system_sandbox_policy,
);
let network =
managed_network_for_sandbox_permissions(req.network.as_ref(), sandbox_permissions)?;
Some(NetworkApprovalSpec {
network: Some(network.clone()),
mode: NetworkApprovalMode::Immediate,
trigger: GuardianNetworkAccessTrigger {
call_id: ctx.call_id.clone(),
tool_name: flat_tool_name(&ctx.tool_name).into_owned(),
command: req.command.clone(),
cwd: PathUri::from_abs_path(&req.cwd),
sandbox_permissions: req.sandbox_permissions,
additional_permissions: req.additional_permissions.clone(),
justification: req.justification.clone(),
tty: None,
},
command: req.hook_command.clone(),
environment_id: req.turn_environment.selection.environment_id.clone(),
permission_profile: req.turn_environment.permission_profile().clone(),
})
}
async fn run(
&mut self,
req: &ShellRequest,
attempt: &SandboxAttempt<'_>,
ctx: &ToolCtx,
) -> Result<ExecToolCallOutput, ToolError> {
let session_shell = ctx.session.user_shell();
let shell = req
.turn_environment
.shell
.as_ref()
.unwrap_or(session_shell.as_ref());
let shell_snapshot_location = req.turn_environment.shell_snapshot(&req.cwd);
let (file_system_sandbox_policy, _) = attempt.permissions.to_runtime_permissions();
let sandbox_permissions = sandbox_permissions_preserving_denied_reads(
req.sandbox_permissions,
&file_system_sandbox_policy,
);
let managed_network =
managed_network_for_sandbox_permissions(req.network.as_ref(), sandbox_permissions);
let mut env = exec_env_for_sandbox_permissions(&req.env, sandbox_permissions);
let explicit_env_overrides = req.explicit_env_overrides.clone();
let cwd = PathUri::from_abs_path(&req.cwd);
let metrics_sidecar = sidecar_for_command(
ctx,
&req.command,
&cwd,
req.turn_environment.environment.as_ref(),
)
.await;
if let Some(sidecar) = metrics_sidecar.as_ref() {
sidecar.install_output_env(&mut env);
}
#[cfg(unix)]
let (env, runtime_path_prepends) = {
let mut env = env;
let mut runtime_path_prepends = RuntimePathPrepends::default();
crate::tools::runtimes::apply_package_path_prepend(
&mut env,
&mut runtime_path_prepends,
);
if self.backend == ShellRuntimeBackend::ShellCommandZshFork
&& let Some(shell_zsh_path) = ctx.session.services.shell_zsh_path.as_deref()
{
apply_zsh_fork_path_prepend(&mut env, &mut runtime_path_prepends, shell_zsh_path);
}
(env, runtime_path_prepends)
};
#[cfg(not(unix))]
let runtime_path_prepends = RuntimePathPrepends::default();
let command = maybe_wrap_shell_lc_with_snapshot(
&req.command,
shell,
shell_snapshot_location.as_ref(),
&explicit_env_overrides,
&env,
&runtime_path_prepends,
);
let command = disable_powershell_profile_for_elevated_windows_sandbox(
&command,
req.shell_type.as_ref(),
attempt.sandbox_requested,
attempt.windows_sandbox_level,
);
let command = if matches!(shell.shell_type, ShellType::PowerShell) {
prefix_powershell_script_with_utf8(&command)
} else {
command
};
let zsh_fork_output = if self.backend == ShellRuntimeBackend::ShellCommandZshFork {
match zsh_fork_backend::maybe_run_shell_command(
req,
attempt,
ctx,
&command,
metrics_sidecar.as_ref(),
)
.await?
{
Some(out) => Some(out),
None => {
tracing::warn!(
"ZshFork backend specified, but conditions for using it were not met, falling back to normal execution",
);
None
}
}
} else {
None
};
let out = if let Some(out) = zsh_fork_output {
out
} else {
let sidecar_permissions = metrics_sidecar
.as_ref()
.map(PluginMetricsSidecar::additional_permissions);
let additional_permissions = merge_permission_profiles(
req.additional_permissions.as_ref(),
sidecar_permissions.as_ref(),
);
let command = build_sandbox_command(&command, &req.cwd, &env, additional_permissions)?;
let mut expiration: crate::exec::ExecExpiration = req.timeout_ms.into();
expiration = expiration.with_cancellation(req.cancellation_token.clone());
if let Some(cancellation) = attempt.network_denial_cancellation_token.clone() {
expiration = expiration.with_cancellation(cancellation);
}
let options = ExecOptions {
expiration,
capture_policy: ExecCapturePolicy::ShellTool,
};
let env = attempt
.env_for(
command,
options,
managed_network,
Some(&req.turn_environment.selection.environment_id),
)
.map_err(ToolError::Codex)?;
execute_env(env, Self::stdout_stream(ctx))
.await
.map_err(ToolError::Codex)?
};
finish_and_track_measurements(
metrics_sidecar,
out.exit_code,
&ctx.session,
&ctx.step_context.turn,
&ctx.call_id,
)
.await;
Ok(out)
}
}
#[cfg(test)]
#[path = "shell_tests.rs"]
mod tests;
@@ -0,0 +1,3 @@
#[cfg(unix)]
pub(crate) mod unix_escalation;
pub(crate) mod zsh_fork_backend;
@@ -1,36 +1,23 @@
use super::ShellRequest;
use crate::exec::ExecCapturePolicy;
use crate::exec::ExecExpiration;
use crate::exec::cancel_when_either;
use crate::exec::is_likely_sandbox_denied;
use crate::guardian::GuardianReviewContext;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::ExecRequest;
use crate::sandboxing::SandboxPermissions;
use crate::shell::ShellType;
use crate::tools::approvals::ApprovalAction;
use crate::tools::approvals::ApprovalContext;
use crate::tools::runtimes::build_sandbox_command;
use crate::tools::runtimes::exec_env_for_sandbox_permissions;
use crate::tools::runtimes::prepend_zsh_fork_bin_to_path;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::managed_network_for_sandbox_permissions;
use crate::tools::sandboxing::sandbox_permissions_preserving_denied_reads;
use crate::tools::sandboxing::unsandboxed_execution_allowed;
use codex_core_plugins::PluginMetricsSidecar;
use codex_execpolicy::Decision;
use codex_execpolicy::Evaluation;
use codex_execpolicy::MatchOptions;
use codex_execpolicy::Policy;
use codex_execpolicy::RuleMatch;
use codex_features::Feature;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::error::CodexErr;
use codex_protocol::error::SandboxErr;
use codex_protocol::exec_output::ExecToolCallOutput;
use codex_protocol::exec_output::StreamOutput;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
@@ -42,8 +29,6 @@ use codex_sandboxing::SandboxManager;
use codex_sandboxing::SandboxTransformRequest;
use codex_sandboxing::SandboxType;
use codex_sandboxing::SandboxablePreference;
use codex_sandboxing::policy_transforms::merge_permission_profiles;
use codex_sandboxing::record_filesystem_sandbox_violation;
use codex_shell_command::bash::parse_shell_lc_plain_commands;
use codex_shell_escalation::EscalateServer;
use codex_shell_escalation::EscalationDecision;
@@ -52,7 +37,6 @@ use codex_shell_escalation::EscalationPermissions;
use codex_shell_escalation::EscalationPolicy;
use codex_shell_escalation::EscalationPolicyFuture;
use codex_shell_escalation::EscalationSession;
use codex_shell_escalation::ExecParams;
use codex_shell_escalation::ExecResult;
use codex_shell_escalation::PreparedExec;
use codex_shell_escalation::ResolvedPermissionProfile;
@@ -66,7 +50,6 @@ use std::collections::HashMap;
use std::io;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
use tracing::error;
@@ -99,178 +82,6 @@ fn approval_sandbox_permissions(
}
}
pub(super) async fn try_run_zsh_fork(
req: &ShellRequest,
attempt: &SandboxAttempt<'_>,
ctx: &ToolCtx,
command: &[String],
metrics_sidecar: Option<&PluginMetricsSidecar>,
) -> Result<Option<ExecToolCallOutput>, ToolError> {
let Some(shell_zsh_path) = ctx.session.services.shell_zsh_path.as_ref() else {
tracing::warn!("ZshFork backend specified, but shell_zsh_path is not configured.");
return Ok(None);
};
if !ctx.session.features().enabled(Feature::ShellZshFork) {
tracing::warn!("ZshFork backend specified, but ShellZshFork feature is not enabled.");
return Ok(None);
}
if !matches!(ctx.session.user_shell().shell_type, ShellType::Zsh) {
tracing::warn!("ZshFork backend specified, but user shell is not Zsh.");
return Ok(None);
}
let (attempt_file_system_sandbox_policy, _) = attempt.permissions.to_runtime_permissions();
let sandbox_permissions = sandbox_permissions_preserving_denied_reads(
req.sandbox_permissions,
&attempt_file_system_sandbox_policy,
);
let req = &ShellRequest {
sandbox_permissions,
..req.clone()
};
let mut env = exec_env_for_sandbox_permissions(&req.env, req.sandbox_permissions);
if let Some(sidecar) = metrics_sidecar {
sidecar.install_output_env(&mut env);
}
prepend_zsh_fork_bin_to_path(&mut env, shell_zsh_path);
let sidecar_permissions = metrics_sidecar.map(PluginMetricsSidecar::additional_permissions);
let additional_permissions = merge_permission_profiles(
req.additional_permissions.as_ref(),
sidecar_permissions.as_ref(),
);
let command = build_sandbox_command(command, &req.cwd, &env, additional_permissions)?;
let options = ExecOptions {
expiration: req.timeout_ms.into(),
capture_policy: ExecCapturePolicy::ShellTool,
};
let sandbox_exec_request = attempt
.env_for(
command,
options,
managed_network_for_sandbox_permissions(req.network.as_ref(), req.sandbox_permissions),
Some(&req.turn_environment.selection.environment_id),
)
.map_err(ToolError::Codex)?;
let crate::sandboxing::ExecRequest {
command,
cwd: sandbox_cwd,
env: sandbox_env,
exec_server_env_config: _,
network: sandbox_network,
network_environment_id,
expiration: _sandbox_expiration,
capture_policy: _capture_policy,
sandbox,
windows_sandbox_policy_cwd: sandbox_policy_cwd,
windows_sandbox_workspace_roots,
windows_sandbox_level,
windows_sandbox_private_desktop: _windows_sandbox_private_desktop,
permission_profile,
windows_sandbox_filesystem_overrides: _windows_sandbox_filesystem_overrides,
arg0,
exec_server_sandbox: _,
exec_server_enforce_managed_network: _,
exec_server_managed_network: _,
exec_server_network_proxy: _,
} = sandbox_exec_request;
let ParsedShellCommand { script, login, .. } = extract_shell_script(&command)?;
let effective_timeout = Duration::from_millis(
req.timeout_ms
.unwrap_or(crate::exec::DEFAULT_EXEC_COMMAND_TIMEOUT_MS),
);
let exec_policy = Arc::new(RwLock::new(
ctx.session
.services
.exec_policy
.current_for_environment(
req.turn_environment.config().exec_policy.as_ref(),
ctx.step_context.turn.allow_prefix_rules(),
)
.as_ref()
.clone(),
));
// TODO(anp): Keep PathUri through the shell escalation boundary.
let sandbox_cwd = sandbox_cwd
.to_abs_path()
.map_err(|err| ToolError::Rejected(err.to_string()))?;
// TODO(anp): Keep PathUri through the shell sandbox policy boundary.
let sandbox_policy_cwd = sandbox_policy_cwd
.to_abs_path()
.map_err(|err| ToolError::Rejected(err.to_string()))?;
let command_executor = CoreShellCommandExecutor {
command,
cwd: sandbox_cwd,
permission_profile,
sandbox,
env: sandbox_env,
network: sandbox_network,
network_environment_id,
windows_sandbox_level,
arg0,
sandbox_policy_cwd,
windows_sandbox_workspace_roots,
codex_linux_sandbox_exe: ctx.step_context.turn.config.codex_linux_sandbox_exe.clone(),
use_legacy_landlock: ctx.step_context.turn.config.features.use_legacy_landlock(),
};
let main_execve_wrapper_exe = ctx
.session
.services
.main_execve_wrapper_exe
.clone()
.ok_or_else(|| {
ToolError::Rejected(
"zsh fork feature enabled, but execve wrapper is not configured".to_string(),
)
})?;
let exec_params = ExecParams {
command: script,
workdir: req.cwd.to_string_lossy().to_string(),
timeout_ms: Some(effective_timeout.as_millis() as u64),
login: Some(login),
};
// Note that Stopwatch starts immediately upon creation, so currently we try
// to minimize the time between creating the Stopwatch and starting the
// escalation server.
let stopwatch = Stopwatch::new(effective_timeout);
let mut cancel_token = stopwatch.cancellation_token();
if let Some(cancellation) = attempt.network_denial_cancellation_token.clone() {
cancel_token = cancel_when_either(cancel_token, cancellation);
}
let approval_sandbox_permissions = approval_sandbox_permissions(
req.sandbox_permissions,
req.additional_permissions_preapproved,
);
let escalation_policy = CoreShellActionProvider {
policy: Arc::clone(&exec_policy),
session: Arc::clone(&ctx.session),
review_context: GuardianReviewContext::from(&ctx.step_context),
call_id: ctx.call_id.clone(),
environment_id: req.turn_environment.selection.environment_id.clone(),
source: GuardianCommandSource::Shell,
tool_name: ctx.tool_name.clone(),
approval_policy: ctx.step_context.turn.approval_policy(),
permission_profile: command_executor.permission_profile.clone(),
sandbox_permissions: req.sandbox_permissions,
approval_sandbox_permissions,
prompt_permissions: req.additional_permissions.clone(),
stopwatch: stopwatch.clone(),
};
let escalate_server = EscalateServer::new(
shell_zsh_path.clone(),
main_execve_wrapper_exe,
escalation_policy,
);
let exec_result = escalate_server
.exec(exec_params, cancel_token, Arc::new(command_executor))
.await
.map_err(|err| ToolError::Rejected(err.to_string()))?;
map_exec_result(attempt.sandbox, exec_result).map(Some)
}
pub(crate) async fn prepare_unified_exec_zsh_fork(
req: &crate::tools::runtimes::unified_exec::UnifiedExecRequest,
_attempt: &SandboxAttempt<'_>,
@@ -1024,36 +835,6 @@ fn extract_shell_script(command: &[String]) -> Result<ParsedShellCommand, ToolEr
))
}
fn map_exec_result(
sandbox: SandboxType,
result: ExecResult,
) -> Result<ExecToolCallOutput, ToolError> {
let output = ExecToolCallOutput {
exit_code: result.exit_code,
stdout: StreamOutput::new(result.stdout.clone()),
stderr: StreamOutput::new(result.stderr.clone()),
aggregated_output: StreamOutput::new(result.output.clone()),
duration: result.duration,
timed_out: result.timed_out,
};
if result.timed_out {
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Timeout {
output: Box::new(output),
})));
}
if is_likely_sandbox_denied(sandbox, &output) {
record_filesystem_sandbox_violation(sandbox, &output);
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
output: Box::new(output),
network_policy_decision: None,
})));
}
Ok(output)
}
/// Convert an intercepted exec `(program, argv)` into a command vector suitable
/// for display and policy parsing.
///
@@ -6,7 +6,6 @@ use super::commands_for_intercepted_exec_policy;
use super::evaluate_intercepted_exec_policy;
use super::extract_shell_script;
use super::join_program_and_argv;
use super::map_exec_result;
use crate::config::Constrained;
use crate::guardian::GuardianReviewContext;
use crate::sandboxing::SandboxPermissions;
@@ -36,7 +35,6 @@ use codex_sandboxing::SandboxType;
use codex_sandboxing::policy_transforms::effective_permission_profile;
use codex_shell_escalation::EscalationExecution;
use codex_shell_escalation::EscalationPermissions;
use codex_shell_escalation::ExecResult;
use codex_shell_escalation::ResolvedPermissionProfile;
use codex_tools::ToolName;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -277,26 +275,6 @@ fn commands_for_intercepted_exec_policy_preserves_unparsed_shell_wrappers() {
}
}
#[test]
fn map_exec_result_preserves_stdout_and_stderr() {
let out = map_exec_result(
SandboxType::None,
ExecResult {
exit_code: 0,
stdout: "out".to_string(),
stderr: "err".to_string(),
output: "outerr".to_string(),
duration: Duration::from_millis(1),
timed_out: false,
},
)
.unwrap();
assert_eq!(out.stdout.text, "out");
assert_eq!(out.stderr.text, "err");
assert_eq!(out.aggregated_output.text, "outerr");
}
#[test]
fn shell_request_escalation_execution_is_explicit() {
let requested_permissions = AdditionalPermissionProfile {
@@ -445,8 +423,8 @@ async fn preapproved_additional_permissions_escalate_intercepted_exec() -> anyho
review_context: GuardianReviewContext::from(Arc::new(turn_context)),
call_id: "preapproved-additional-permissions".to_string(),
environment_id: "local".to_string(),
source: GuardianCommandSource::Shell,
tool_name: ToolName::plain("shell_command"),
source: GuardianCommandSource::UnifiedExec,
tool_name: ToolName::plain("exec_command"),
approval_policy: AskForApproval::OnRequest,
permission_profile: permission_profile.clone(),
sandbox_permissions: SandboxPermissions::WithAdditionalPermissions,
@@ -613,8 +591,8 @@ async fn execve_permission_request_hook_short_circuits_prompt() -> anyhow::Resul
review_context: GuardianReviewContext::from(turn_context),
call_id: "execve-hook-call".to_string(),
environment_id: "local".to_string(),
source: GuardianCommandSource::Shell,
tool_name: ToolName::plain("shell_command"),
source: GuardianCommandSource::UnifiedExec,
tool_name: ToolName::plain("exec_command"),
approval_policy: AskForApproval::OnRequest,
permission_profile: PermissionProfile::read_only(),
sandbox_permissions: SandboxPermissions::RequireEscalated,
@@ -824,8 +802,8 @@ prefix_rule(pattern = ["{cat_path_literal}"], decision = "allow")
review_context: GuardianReviewContext::from(Arc::new(turn_context)),
call_id: "deny-read-prefix-allow".to_string(),
environment_id: "local".to_string(),
source: GuardianCommandSource::Shell,
tool_name: ToolName::plain("shell_command"),
source: GuardianCommandSource::UnifiedExec,
tool_name: ToolName::plain("exec_command"),
approval_policy: AskForApproval::OnRequest,
permission_profile,
sandbox_permissions: SandboxPermissions::UseDefault,
@@ -861,8 +839,8 @@ async fn denied_reads_keep_granular_sandbox_rejection_for_escalation() -> anyhow
review_context: GuardianReviewContext::from(Arc::new(turn_context)),
call_id: "deny-read-granular-sandbox-reject".to_string(),
environment_id: "local".to_string(),
source: GuardianCommandSource::Shell,
tool_name: ToolName::plain("shell_command"),
source: GuardianCommandSource::UnifiedExec,
tool_name: ToolName::plain("exec_command"),
approval_policy: AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: false,
rules: true,
@@ -1,12 +1,9 @@
use super::ShellRequest;
use crate::sandboxing::ExecRequest;
use crate::tools::runtimes::unified_exec::UnifiedExecRequest;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use crate::unified_exec::SpawnLifecycleHandle;
use codex_core_plugins::PluginMetricsSidecar;
use codex_protocol::exec_output::ExecToolCallOutput;
use codex_tools::ZshForkConfig;
pub(crate) struct PreparedUnifiedExecSpawn {
@@ -14,21 +11,6 @@ pub(crate) struct PreparedUnifiedExecSpawn {
pub(crate) spawn_lifecycle: SpawnLifecycleHandle,
}
/// Runs the zsh-fork shell-command backend when this request should be handled
/// by executable-level escalation instead of the default shell runtime.
///
/// Returns `Ok(None)` when the current platform or request shape should fall
/// back to the normal shell-command path.
pub(crate) async fn maybe_run_shell_command(
req: &ShellRequest,
attempt: &SandboxAttempt<'_>,
ctx: &ToolCtx,
command: &[String],
metrics_sidecar: Option<&PluginMetricsSidecar>,
) -> Result<Option<ExecToolCallOutput>, ToolError> {
imp::maybe_run_shell_command(req, attempt, ctx, command, metrics_sidecar).await
}
/// Prepares unified exec to launch through the zsh-fork backend when the
/// request matches a wrapped `zsh -c/-lc` command on a supported platform.
///
@@ -73,16 +55,6 @@ mod imp {
}
}
pub(super) async fn maybe_run_shell_command(
req: &ShellRequest,
attempt: &SandboxAttempt<'_>,
ctx: &ToolCtx,
command: &[String],
metrics_sidecar: Option<&PluginMetricsSidecar>,
) -> Result<Option<ExecToolCallOutput>, ToolError> {
unix_escalation::try_run_zsh_fork(req, attempt, ctx, command, metrics_sidecar).await
}
pub(super) async fn maybe_prepare_unified_exec(
req: &UnifiedExecRequest,
attempt: &SandboxAttempt<'_>,
@@ -116,17 +88,6 @@ mod imp {
mod imp {
use super::*;
pub(super) async fn maybe_run_shell_command(
req: &ShellRequest,
attempt: &SandboxAttempt<'_>,
ctx: &ToolCtx,
command: &[String],
metrics_sidecar: Option<&PluginMetricsSidecar>,
) -> Result<Option<ExecToolCallOutput>, ToolError> {
let _ = (req, attempt, ctx, command, metrics_sidecar);
Ok(None)
}
pub(super) async fn maybe_prepare_unified_exec(
req: &UnifiedExecRequest,
attempt: &SandboxAttempt<'_>,
@@ -1,81 +0,0 @@
use super::*;
use crate::config::PermissionProfileSnapshot;
use crate::environment_selection::EnvironmentConfigOrigin;
use crate::tools::approvals::ApprovalCacheKey;
use codex_exec_server::Environment;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::EnvironmentConfig;
use codex_protocol::protocol::EnvironmentConfigState;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
use std::sync::Arc;
#[tokio::test]
async fn approval_key_uses_path_uri_and_includes_environment_id() {
let cwd = AbsolutePathBuf::try_from(std::env::current_dir().expect("read current dir"))
.expect("current dir is absolute");
let mut request = ShellRequest {
command: vec!["echo".to_string(), "hello".to_string()],
turn_environment: TurnEnvironment::new(
TurnEnvironmentSelection {
environment_id: "remote".to_string(),
cwd: PathUri::from_abs_path(&cwd),
workspace_roots: Vec::new(),
config: EnvironmentConfigState::Ready(EnvironmentConfig {
allow_login_shell: true,
permission_profile: PermissionProfileSnapshot::legacy(
PermissionProfile::read_only(),
),
shell_environment_policy: Default::default(),
exec_policy: None,
mcp_policy: None,
network_policy: None,
selected_capability_roots: Vec::new(),
}),
},
EnvironmentConfigOrigin::Thread,
Arc::new(Environment::default_for_tests()),
/*shell*/ None,
),
shell_type: None,
hook_command: "echo hello".to_string(),
cwd: cwd.clone(),
timeout_ms: None,
cancellation_token: CancellationToken::new(),
env: HashMap::new(),
explicit_env_overrides: HashMap::new(),
network: None,
sandbox_permissions: SandboxPermissions::UseDefault,
additional_permissions: None,
#[cfg(unix)]
additional_permissions_preapproved: false,
justification: None,
exec_approval_requirement: ExecApprovalRequirement::Skip {
bypass_sandbox: false,
proposed_execpolicy_amendment: None,
},
};
let runtime = ShellRuntime::for_shell_command(ShellRuntimeBackend::ShellCommandClassic);
let original_key = runtime
.approval_action(&request, "call-1")
.expect("build approval action")
.cache_keys();
assert_eq!(
original_key,
vec![ApprovalCacheKey::Shell(ApprovalKey {
environment_id: "remote".to_string(),
command: request.command.clone(),
cwd: PathUri::from_abs_path(&cwd),
sandbox_permissions: request.sandbox_permissions,
additional_permissions: request.additional_permissions.clone(),
})]
);
request.turn_environment.selection.environment_id = "other".to_string();
let other_key = runtime
.approval_action(&request, "call-1")
.expect("build approval action")
.cache_keys();
assert_ne!(original_key, other_key);
}
@@ -16,7 +16,6 @@ use crate::sandboxing::SandboxPermissions;
use crate::session::turn_context::TurnEnvironment;
use crate::shell::ShellType;
use crate::tools::flat_tool_name;
use crate::tools::network_approval::NetworkApprovalMode;
use crate::tools::network_approval::NetworkApprovalSpec;
use crate::tools::runtimes::RuntimePathPrepends;
#[cfg(unix)]
@@ -226,7 +225,6 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecAttempt> for UnifiedExecRunt
managed_network_for_sandbox_permissions(req.network.as_ref(), sandbox_permissions)?;
Some(NetworkApprovalSpec {
network: Some(network.clone()),
mode: NetworkApprovalMode::Deferred,
trigger: GuardianNetworkAccessTrigger {
call_id: ctx.call_id.clone(),
tool_name: flat_tool_name(&ctx.tool_name).into_owned(),
+1 -26
View File
@@ -25,8 +25,6 @@ use crate::tools::handlers::RequestPermissionsHandler;
use crate::tools::handlers::RequestPluginInstallHandler;
use crate::tools::handlers::RequestUserInputHandler;
use crate::tools::handlers::SendUserMessageAsyncHandler;
use crate::tools::handlers::ShellCommandHandler;
use crate::tools::handlers::ShellCommandHandlerOptions;
use crate::tools::handlers::SleepHandler;
use crate::tools::handlers::TestSyncHandler;
use crate::tools::handlers::ToolSearchHandlerCache;
@@ -92,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_command_backend_for_features;
use codex_tools::shell_type_for_model_and_features;
use futures::future::BoxFuture;
use std::collections::BTreeMap;
@@ -970,13 +967,6 @@ fn add_shell_tools(context: &CoreToolPlanContext<'_>, registry: &mut ToolRegistr
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);
let supports_shell_command = context.environments.single_local_environment().is_some();
let shell_command_options = ShellCommandHandlerOptions {
backend_config: shell_command_backend_for_features(features),
allow_login_shell,
exec_permission_approvals_enabled,
};
match shell_type_for_model_and_features(&turn_context.model_info, features) {
ConfigShellToolType::UnifiedExec => {
registry.add(ExecCommandHandler::new(ExecCommandHandlerOptions {
@@ -989,24 +979,9 @@ fn add_shell_tools(context: &CoreToolPlanContext<'_>, registry: &mut ToolRegistr
),
}));
registry.add(WriteStdinHandler);
if supports_shell_command {
// Keep the legacy shell tool registered while unified exec is
// model-visible.
registry.add_with_exposure(
ShellCommandHandler::new(shell_command_options),
ToolExposure::Hidden,
);
}
}
ConfigShellToolType::Disabled => {}
ConfigShellToolType::Default
| ConfigShellToolType::Local
| ConfigShellToolType::ShellCommand => {
if supports_shell_command {
registry.add(ShellCommandHandler::new(shell_command_options));
}
}
ConfigShellToolType::Default | ConfigShellToolType::Local => {}
}
}
+40 -55
View File
@@ -660,34 +660,28 @@ async fn request_user_input_stays_direct_in_code_mode_only() {
}
#[tokio::test]
async fn shell_family_registers_visible_unified_exec_and_hidden_legacy_shell() {
async fn shell_family_registers_only_unified_exec_tools() {
let plan = probe(|turn| {
set_features(turn, &[Feature::ShellTool, Feature::UnifiedExec]);
set_feature(turn, Feature::ShellZshFork, /*enabled*/ false);
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::ShellCommand;
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec;
})
.await;
plan.assert_visible_contains(&["exec_command", "write_stdin"]);
plan.assert_visible_lacks(&["shell_command"]);
plan.assert_registered_contains(&["exec_command", "write_stdin", "shell_command"]);
assert_eq!(plan.exposure("shell_command"), ToolExposure::Hidden);
plan.assert_registered_contains(&["exec_command", "write_stdin"]);
assert!(has_parameter(plan.visible_spec("exec_command"), "shell"));
}
#[tokio::test]
async fn login_shell_parameter_follows_selected_environment() {
for (tool_name, guardian) in [
("shell_command", false),
("exec_command", false),
("exec_command", true),
] {
for guardian in [false, true] {
for allow_login_shell in [false, true] {
let plan = probe(|turn| {
set_feature(turn, Feature::ShellTool, /*enabled*/ true);
set_feature(turn, Feature::UnifiedExec, tool_name == "exec_command");
set_feature(turn, Feature::UnifiedExec, /*enabled*/ true);
set_feature(turn, Feature::ShellZshFork, /*enabled*/ false);
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::ShellCommand;
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec;
update_config(turn, |config| {
config.permissions.allow_login_shell = !allow_login_shell;
});
@@ -711,7 +705,7 @@ async fn login_shell_parameter_follows_selected_environment() {
.await;
assert_eq!(
has_parameter(plan.visible_spec(tool_name), "login"),
has_parameter(plan.visible_spec("exec_command"), "login"),
allow_login_shell
);
}
@@ -740,11 +734,10 @@ async fn login_shell_parameter_is_available_when_any_environment_allows_it() {
}
#[tokio::test]
async fn shell_command_is_not_registered_without_a_single_local_environment() {
async fn disabling_shell_tools_disables_command_tools_for_all_environments() {
let remote_environment = probe(|turn| {
set_feature(turn, Feature::ShellTool, /*enabled*/ true);
set_feature(turn, Feature::UnifiedExec, /*enabled*/ false);
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::ShellCommand;
set_feature(turn, Feature::ShellTool, /*enabled*/ false);
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec;
let TurnEnvironmentState::Ready(environment) = turn
.environments
@@ -767,64 +760,63 @@ async fn shell_command_is_not_registered_without_a_single_local_environment() {
remote_environment.assert_registered_lacks(&["shell_command", "exec_command", "write_stdin"]);
let multiple_local_environments = probe(|turn| {
set_feature(turn, Feature::ShellTool, /*enabled*/ true);
set_feature(turn, Feature::UnifiedExec, /*enabled*/ false);
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::ShellCommand;
set_feature(turn, Feature::ShellTool, /*enabled*/ false);
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec;
duplicate_primary_environment(turn);
})
.await;
multiple_local_environments.assert_visible_lacks(&["shell_command"]);
multiple_local_environments.assert_registered_lacks(&["shell_command"]);
multiple_local_environments.assert_visible_lacks(&[
"shell_command",
"exec_command",
"write_stdin",
]);
multiple_local_environments.assert_registered_lacks(&[
"shell_command",
"exec_command",
"write_stdin",
]);
}
#[tokio::test]
async fn dynamic_tools_cannot_reclaim_the_reserved_shell_command_name() {
async fn dynamic_tools_cannot_reclaim_the_reserved_exec_command_name() {
let plan = probe_with(
duplicate_primary_environment,
ToolPlanInputs {
dynamic_tools: vec![
dynamic_tool(
/*namespace*/ None,
"shell_command",
/*defer_loading*/ false,
),
dynamic_tool(
Some("client"),
"shell_command",
"exec_command",
/*defer_loading*/ false,
),
dynamic_tool(Some("client"), "exec_command", /*defer_loading*/ false),
],
..ToolPlanInputs::default()
},
)
.await;
plan.assert_visible_lacks(&["shell_command"]);
plan.assert_registered_lacks(&["shell_command"]);
plan.assert_visible_contains(&["exec_command"]);
plan.assert_registered_contains(&["exec_command"]);
plan.assert_visible_contains(&["client"]);
plan.assert_registered_contains(
&[&ToolName::namespaced("client", "shell_command").to_string()],
);
plan.assert_registered_contains(&[&ToolName::namespaced("client", "exec_command").to_string()]);
assert_eq!(
plan.namespace_function_names("client"),
&["shell_command".to_string()]
&["exec_command".to_string()]
);
}
#[tokio::test]
async fn shell_zsh_fork_stays_standalone_until_unified_exec_composition_is_enabled() {
let standalone = probe(|turn| {
async fn shell_zsh_fork_keeps_unified_exec_available() {
let without_composition = probe(|turn| {
set_features(turn, &[Feature::ShellTool, Feature::UnifiedExec]);
set_feature(turn, Feature::ShellZshFork, /*enabled*/ true);
set_feature(turn, Feature::UnifiedExecZshFork, /*enabled*/ false);
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::ShellCommand;
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec;
})
.await;
standalone.assert_visible_contains(&["shell_command"]);
standalone.assert_visible_lacks(&["exec_command", "write_stdin"]);
standalone.assert_registered_contains(&["shell_command"]);
standalone.assert_registered_lacks(&["exec_command", "write_stdin"]);
without_composition.assert_visible_contains(&["exec_command", "write_stdin"]);
without_composition.assert_registered_contains(&["exec_command", "write_stdin"]);
let composed = probe(|turn| {
set_features(
@@ -836,19 +828,12 @@ async fn shell_zsh_fork_stays_standalone_until_unified_exec_composition_is_enabl
Feature::UnifiedExecZshFork,
],
);
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::ShellCommand;
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec;
})
.await;
if codex_utils_pty::conpty_supported() {
composed.assert_visible_contains(&["exec_command", "write_stdin"]);
composed.assert_visible_lacks(&["shell_command"]);
composed.assert_registered_contains(&["exec_command", "write_stdin", "shell_command"]);
assert_eq!(composed.exposure("shell_command"), ToolExposure::Hidden);
} else {
composed.assert_visible_contains(&["shell_command"]);
composed.assert_visible_lacks(&["exec_command", "write_stdin"]);
}
composed.assert_visible_contains(&["exec_command", "write_stdin"]);
composed.assert_registered_contains(&["exec_command", "write_stdin"]);
}
#[tokio::test]
@@ -958,15 +943,15 @@ async fn environment_count_controls_environment_backed_tools() {
})
.await;
no_environment.assert_visible_lacks(&[
"shell_command",
"exec_command",
"write_stdin",
"apply_patch",
"view_image",
"request_permissions",
]);
no_environment.assert_registered_lacks(&[
"shell_command",
"exec_command",
"write_stdin",
"apply_patch",
"view_image",
"request_permissions",
@@ -1363,7 +1348,7 @@ async fn unified_tool_runtimes_preserve_source_order_and_collision_priority() {
|turn| {
set_features(turn, &[Feature::ShellTool, Feature::UnifiedExec]);
set_feature(turn, Feature::ShellZshFork, /*enabled*/ false);
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::ShellCommand;
Arc::make_mut(&mut turn.model_info).shell_type = ConfigShellToolType::UnifiedExec;
},
ToolPlanInputs {
tool_runtimes: vec![mcp_runtime(
+9 -9
View File
@@ -1001,21 +1001,21 @@ pub fn ev_apply_patch_custom_tool_call(call_id: &str, patch: &str) -> Value {
})
}
pub fn ev_shell_command_call(call_id: &str, command: &str) -> Value {
let args = serde_json::json!({ "command": command });
ev_shell_command_call_with_args(call_id, &args)
pub fn ev_exec_command_call(call_id: &str, command: &str) -> Value {
let args = serde_json::json!({ "cmd": command });
ev_exec_command_call_with_args(call_id, &args)
}
pub fn ev_shell_command_call_with_args(call_id: &str, args: &serde_json::Value) -> Value {
let arguments = serde_json::to_string(args).expect("serialize shell command arguments");
ev_function_call(call_id, "shell_command", &arguments)
pub fn ev_exec_command_call_with_args(call_id: &str, args: &serde_json::Value) -> Value {
let arguments = serde_json::to_string(args).expect("serialize exec command arguments");
ev_function_call(call_id, "exec_command", &arguments)
}
pub fn ev_apply_patch_shell_command_call_via_heredoc(call_id: &str, patch: &str) -> Value {
let args = serde_json::json!({ "command": format!("apply_patch <<'EOF'\n{patch}\nEOF\n") });
pub fn ev_apply_patch_exec_command_call_via_heredoc(call_id: &str, patch: &str) -> Value {
let args = serde_json::json!({ "cmd": format!("apply_patch <<'EOF'\n{patch}\nEOF\n") });
let arguments = serde_json::to_string(&args).expect("serialize apply_patch arguments");
ev_function_call(call_id, "shell_command", &arguments)
ev_function_call(call_id, "exec_command", &arguments)
}
pub fn sse_failed(id: &str, code: &str, message: &str) -> String {
+1 -1
View File
@@ -308,7 +308,7 @@ fn docker_command_capture_stdout<const N: usize>(args: [&str; N]) -> Result<Stri
/// Non-default apply_patch model output shapes used by compatibility tests.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ApplyPatchModelOutput {
ShellCommandViaHeredoc,
ExecCommandViaHeredoc,
}
/// Returns the permission fields required by test thread-settings overrides.
+1 -1
View File
@@ -562,7 +562,7 @@ async fn responses_stream_includes_turn_metadata_header_for_git_workspace_e2e()
let first_response = responses::sse(vec![
responses::ev_response_created("resp-2"),
responses::ev_reasoning_item("rsn-1", &["thinking"], &[]),
responses::ev_shell_command_call("call-1", "echo turn-metadata"),
responses::ev_exec_command_call("call-1", "echo turn-metadata"),
responses::ev_completed("resp-2"),
]);
let follow_up_response = responses::sse(vec![
+16 -13
View File
@@ -18,31 +18,31 @@ use core_test_support::wait_for_event;
use regex_lite::Regex;
use serde_json::json;
/// Integration test: spawn a long‑running shell_command tool via a mocked Responses SSE
/// Integration test: spawn a long‑running exec_command tool via a mocked Responses SSE
/// function call, then interrupt the session and expect TurnAborted.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn interrupt_long_running_tool_emits_turn_aborted() {
let command = "sleep 60";
let args = json!({
"command": command,
"timeout_ms": 60_000
"cmd": command,
"yield_time_ms": 60_000
})
.to_string();
let body = sse(vec![
ev_function_call("call_sleep", "shell_command", &args),
ev_function_call("call_sleep", "exec_command", &args),
ev_completed("done"),
]);
let server = start_mock_server().await;
mount_sse_once(&server, body).await;
let codex = test_codex()
let fixture = test_codex()
.with_model("gpt-5.4")
.build(&server)
.await
.unwrap()
.codex;
.unwrap();
let codex = Arc::clone(&fixture.codex);
// Kick off a turn that triggers the function call.
codex
@@ -60,6 +60,7 @@ async fn interrupt_long_running_tool_emits_turn_aborted() {
// Expect TurnAborted soon after.
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnAborted(_))).await;
codex.submit(Op::CleanBackgroundTerminals).await.unwrap();
}
/// After an interrupt we expect the next request to the model to include both
@@ -72,13 +73,13 @@ async fn interrupt_tool_records_history_entries() {
let call_id = "call-history";
let args = json!({
"command": command,
"timeout_ms": 60_000
"cmd": command,
"yield_time_ms": 60_000
})
.to_string();
let first_body = sse(vec![
ev_response_created("resp-history"),
ev_function_call(call_id, "shell_command", &args),
ev_function_call(call_id, "exec_command", &args),
ev_completed("resp-history"),
]);
let follow_up_body = sse(vec![
@@ -154,6 +155,7 @@ async fn interrupt_tool_records_history_entries() {
secs >= 0.1,
"expected at least one tenth of a second of elapsed time, got {secs}"
);
codex.submit(Op::CleanBackgroundTerminals).await.unwrap();
}
/// After an interrupt we persist a model-visible `<turn_aborted>` marker in the conversation
@@ -164,13 +166,13 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() {
let call_id = "call-turn-aborted-marker";
let args = json!({
"command": command,
"timeout_ms": 60_000
"cmd": command,
"yield_time_ms": 60_000
})
.to_string();
let first_body = sse(vec![
ev_response_created("resp-marker"),
ev_function_call(call_id, "shell_command", &args),
ev_function_call(call_id, "exec_command", &args),
ev_completed("resp-marker"),
]);
let follow_up_body = sse(vec![
@@ -224,4 +226,5 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() {
.any(|text| text.contains("<turn_aborted>")),
"expected <turn_aborted> marker in follow-up request"
);
codex.submit(Op::CleanBackgroundTerminals).await.unwrap();
}
+8 -8
View File
@@ -9,8 +9,8 @@ use codex_protocol::user_input::UserInput;
use core_test_support::responses::WebSocketConnectionConfig;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_exec_command_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::ev_shell_command_call;
use core_test_support::responses::start_websocket_server;
use core_test_support::responses::start_websocket_server_with_headers;
use core_test_support::skip_if_no_network;
@@ -119,11 +119,11 @@ async fn websocket_model_switch_to_responses_lite_omits_top_level_tools() -> Res
async fn websocket_test_codex_shell_chain() -> Result<()> {
skip_if_no_network!(Ok(()));
let call_id = "shell-command-call";
let call_id = "exec-command-call";
let server = start_websocket_server(vec![vec![
vec![
ev_response_created("resp-1"),
ev_shell_command_call(call_id, "echo websocket"),
ev_exec_command_call(call_id, "echo websocket"),
ev_completed("resp-1"),
],
vec![
@@ -273,16 +273,16 @@ async fn websocket_first_turn_handles_handshake_delay_with_startup_prewarm() ->
async fn websocket_v2_test_codex_shell_chain() -> Result<()> {
skip_if_no_network!(Ok(()));
let call_id = "shell-command-call";
let mut shell_command_call = ev_shell_command_call(call_id, "echo websocket");
shell_command_call["item"]["id"] = serde_json::json!("fc_shell_command_call");
shell_command_call["item"]["internal_chat_message_metadata_passthrough"] =
let call_id = "exec-command-call";
let mut exec_command_call = ev_exec_command_call(call_id, "echo websocket");
exec_command_call["item"]["id"] = serde_json::json!("fc_exec_command_call");
exec_command_call["item"]["internal_chat_message_metadata_passthrough"] =
serde_json::json!({"turn_id": "turn-123"});
let server = start_websocket_server(vec![vec![
vec![ev_response_created("warm-1"), ev_completed("warm-1")],
vec![
ev_response_created("resp-1"),
shell_command_call,
exec_command_call,
ev_completed("resp-1"),
],
vec![
+25 -25
View File
@@ -4,8 +4,8 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_core::StartThreadOptions;
use codex_core::TurnInputRequest;
use core_test_support::responses::ev_apply_patch_custom_tool_call;
use core_test_support::responses::ev_apply_patch_shell_command_call_via_heredoc;
use core_test_support::responses::ev_shell_command_call;
use core_test_support::responses::ev_apply_patch_exec_command_call_via_heredoc;
use core_test_support::responses::ev_exec_command_call;
use core_test_support::test_codex::ApplyPatchModelOutput;
use pretty_assertions::assert_eq;
use std::fs;
@@ -53,9 +53,9 @@ use core_test_support::TestTargetOs;
use core_test_support::assert_regex_match;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_exec_command_call_with_args;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::ev_shell_command_call_with_args;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
@@ -251,8 +251,8 @@ async fn mount_apply_patch_model_output(
model_output: ApplyPatchModelOutput,
) {
let apply_patch_call = match model_output {
ApplyPatchModelOutput::ShellCommandViaHeredoc => {
ev_apply_patch_shell_command_call_via_heredoc
ApplyPatchModelOutput::ExecCommandViaHeredoc => {
ev_apply_patch_exec_command_call_via_heredoc
}
};
@@ -300,13 +300,13 @@ async fn assert_apply_patch_crlf_update(
CrLfApplyPatchModelOutput::CustomTool => {
mount_apply_patch(&harness, call_id, &patch, "apply_patch done").await;
}
CrLfApplyPatchModelOutput::ShellCommandViaHeredoc => {
CrLfApplyPatchModelOutput::ExecCommandViaHeredoc => {
mount_apply_patch_model_output(
&harness,
call_id,
&patch,
"apply_patch done",
ApplyPatchModelOutput::ShellCommandViaHeredoc,
ApplyPatchModelOutput::ExecCommandViaHeredoc,
)
.await;
}
@@ -327,7 +327,7 @@ async fn assert_apply_patch_crlf_update(
#[derive(Clone, Copy)]
enum CrLfApplyPatchModelOutput {
CustomTool,
ShellCommandViaHeredoc,
ExecCommandViaHeredoc,
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -363,7 +363,7 @@ async fn apply_patch_shell_heredoc_normalizes_crlf_without_preserve_line_endings
skip_if_wine_exec!(Ok(()), "uses a POSIX shell heredoc");
assert_apply_patch_crlf_update(
|builder| builder,
CrLfApplyPatchModelOutput::ShellCommandViaHeredoc,
CrLfApplyPatchModelOutput::ExecCommandViaHeredoc,
"after\n",
)
.await
@@ -382,7 +382,7 @@ async fn apply_patch_shell_heredoc_preserves_crlf_with_preserve_line_endings_fea
.expect("feature should be enabled");
})
},
CrLfApplyPatchModelOutput::ShellCommandViaHeredoc,
CrLfApplyPatchModelOutput::ExecCommandViaHeredoc,
"after\r\n",
)
.await
@@ -907,7 +907,7 @@ async fn intercepted_apply_patch_verification_uses_local_sandbox() -> Result<()>
call_id,
&patch,
"fail",
ApplyPatchModelOutput::ShellCommandViaHeredoc,
ApplyPatchModelOutput::ExecCommandViaHeredoc,
)
.await;
@@ -1330,7 +1330,7 @@ async fn apply_patch_cli_verification_failure_has_no_side_effects() -> Result<()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_shell_command_heredoc_with_cd_updates_relative_workdir() -> Result<()> {
async fn apply_patch_exec_command_heredoc_with_cd_updates_relative_workdir() -> Result<()> {
// TODO(anp): Remove after apply_patch shell fixtures use target-native commands.
skip_if_wine_exec!(Ok(()), "uses a POSIX shell heredoc and cd command");
skip_if_no_network!(Ok(()));
@@ -1345,7 +1345,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_updates_relative_workdir() ->
let bodies = vec![
sse(vec![
ev_response_created("resp-1"),
ev_shell_command_call(call_id, script),
ev_exec_command_call(call_id, script),
ev_completed("resp-1"),
]),
sse(vec![
@@ -1360,18 +1360,18 @@ async fn apply_patch_shell_command_heredoc_with_cd_updates_relative_workdir() ->
let out = harness.function_call_stdout(call_id).await;
assert!(
out.contains("Success."),
"expected successful apply_patch invocation via shell_command: {out}"
"expected successful apply_patch invocation via exec_command: {out}"
);
assert_eq!(harness.read_file_text("sub/in_sub.txt").await?, "after\n");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_cli_can_use_shell_command_output_as_patch_input() -> Result<()> {
async fn apply_patch_cli_can_use_exec_command_output_as_patch_input() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_remote!(
Ok(()),
"shell_command output producer runs in the test runner, not in the remote apply_patch workspace",
"exec_command output producer runs in the test runner, not in the remote apply_patch workspace",
);
let harness =
@@ -1438,12 +1438,12 @@ async fn apply_patch_cli_can_use_shell_command_output_as_patch_input() -> Result
"cat source.txt".to_string()
};
let args = json!({
"command": command,
"cmd": command,
"login": false,
});
let body = sse(vec![
ev_response_created("resp-1"),
ev_shell_command_call_with_args(&self.read_call_id, &args),
ev_exec_command_call_with_args(&self.read_call_id, &args),
ev_completed("resp-1"),
]);
ResponseTemplate::new(200)
@@ -1614,7 +1614,7 @@ async fn apply_patch_custom_tool_streaming_emits_updated_changes() -> Result<()>
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<()> {
async fn apply_patch_exec_command_heredoc_with_cd_emits_turn_diff() -> Result<()> {
// TODO(anp): Remove after apply_patch shell fixtures use target-native commands.
skip_if_wine_exec!(Ok(()), "uses a POSIX shell heredoc and cd command");
skip_if_no_network!(Ok(()));
@@ -1628,11 +1628,11 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<(
let script = "cd sub && apply_patch <<'EOF'\n*** Begin Patch\n*** Update File: in_sub.txt\n@@\n-before\n+after\n*** End Patch\nEOF\n";
let call_id = "shell-heredoc-cd";
let args = json!({ "command": script, "timeout_ms": 30_000 });
let args = json!({ "cmd": script, "yield_time_ms": 30_000 });
let bodies = vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
sse(vec![
@@ -1866,7 +1866,7 @@ async fn apply_patch_turn_diff_skips_git_root_when_feature_is_enabled(
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> Result<()> {
async fn apply_patch_exec_command_failure_propagates_error_and_skips_diff() -> Result<()> {
// TODO(anp): Remove after apply_patch shell fixtures use target-native commands.
skip_if_wine_exec!(Ok(()), "uses a POSIX shell heredoc");
skip_if_no_network!(Ok(()));
@@ -1879,11 +1879,11 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() ->
let script = "apply_patch <<'EOF'\n*** Begin Patch\n*** Update File: invalid.txt\n@@\n-nope\n+changed\n*** End Patch\nEOF\n";
let call_id = "shell-apply-failure";
let args = json!({ "command": script, "timeout_ms": 5_000 });
let args = json!({ "cmd": script, "yield_time_ms": 5_000 });
let bodies = vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
sse(vec![
@@ -1941,7 +1941,7 @@ async fn apply_patch_shell_accepts_lenient_heredoc_wrapped_patch() -> Result<()>
call_id,
patch_inner.as_str(),
"ok",
ApplyPatchModelOutput::ShellCommandViaHeredoc,
ApplyPatchModelOutput::ExecCommandViaHeredoc,
)
.await;
@@ -0,0 +1,127 @@
#![cfg(not(target_os = "windows"))]
use anyhow::Result;
use codex_protocol::models::PermissionProfile;
use core_test_support::assert_regex_match;
use core_test_support::skip_if_no_network;
use core_test_support::skip_if_target_windows;
use pretty_assertions::assert_eq;
use crate::suite::apply_patch_cli::apply_patch_harness;
use crate::suite::apply_patch_cli::mount_apply_patch;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_custom_tool_call_creates_file() -> Result<()> {
skip_if_no_network!(Ok(()));
let harness = apply_patch_harness().await?;
let call_id = "apply-patch-add-file";
let file_name = "custom_tool_apply_patch.txt";
let patch = format!(
"*** Begin Patch\n*** Add File: {file_name}\n+custom tool content\n*** End Patch\n"
);
mount_apply_patch(&harness, call_id, &patch, "apply_patch done").await;
harness
.test()
.submit_turn_with_permission_profile(
"apply the patch via custom tool to create a file",
PermissionProfile::Disabled,
)
.await?;
let output = harness.apply_patch_output(call_id).await;
let expected_pattern = format!(
r"(?s)^Exit code: 0
Wall time: [0-9]+(?:\.[0-9]+)? seconds
Output:
Success. Updated the following files:
A {file_name}
?$"
);
assert_regex_match(&expected_pattern, output.as_str());
let created_contents = harness.read_file_text(file_name).await?;
assert_eq!(
created_contents, "custom tool content\n",
"expected file contents for {file_name}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_custom_tool_call_updates_existing_file() -> Result<()> {
skip_if_no_network!(Ok(()));
let harness = apply_patch_harness().await?;
let call_id = "apply-patch-update-file";
let file_name = "custom_tool_apply_patch_existing.txt";
harness.write_file(file_name, "before\n").await?;
let patch = format!(
"*** Begin Patch\n*** Update File: {file_name}\n@@\n-before\n+after\n*** End Patch\n"
);
mount_apply_patch(&harness, call_id, &patch, "apply_patch update done").await;
harness
.test()
.submit_turn_with_permission_profile(
"apply the patch via custom tool to update a file",
PermissionProfile::Disabled,
)
.await?;
let output = harness.apply_patch_output(call_id).await;
let expected_pattern = format!(
r"(?s)^Exit code: 0
Wall time: [0-9]+(?:\.[0-9]+)? seconds
Output:
Success. Updated the following files:
M {file_name}
?$"
);
assert_regex_match(&expected_pattern, output.as_str());
let updated_contents = harness.read_file_text(file_name).await?;
assert_eq!(updated_contents, "after\n", "expected updated file content");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_custom_tool_call_reports_failure_output() -> Result<()> {
// TODO(anp): Remove after apply-patch assertions use target-native paths.
skip_if_target_windows!(Ok(()), "asserts POSIX apply_patch failure text");
skip_if_no_network!(Ok(()));
let harness = apply_patch_harness().await?;
let call_id = "apply-patch-failure";
let missing_file = "missing_custom_tool_apply_patch.txt";
let patch = format!(
"*** Begin Patch\n*** Update File: {missing_file}\n@@\n-before\n+after\n*** End Patch\n"
);
mount_apply_patch(&harness, call_id, &patch, "apply_patch failure done").await;
harness
.test()
.submit_turn_with_permission_profile(
"attempt a failing apply_patch via custom tool",
PermissionProfile::Disabled,
)
.await?;
let output = harness.apply_patch_output(call_id).await;
let expected_output = format!(
"apply_patch verification failed: Failed to read file to update {}/{missing_file}: No such file or directory (os error 2)",
harness.cwd().to_string_lossy()
);
assert_eq!(output, expected_output.as_str());
Ok(())
}
+17 -76
View File
@@ -342,17 +342,18 @@ fn shell_event_with_prefix_rule(
prefix_rule: Option<Vec<String>>,
) -> Result<Value> {
let mut args = json!({
"command": command,
"timeout_ms": timeout_ms,
"cmd": command,
"yield_time_ms": timeout_ms,
});
if sandbox_permissions.requests_sandbox_override() {
args["sandbox_permissions"] = json!(sandbox_permissions);
args["justification"] = json!(DEFAULT_UNIFIED_EXEC_JUSTIFICATION);
}
if let Some(prefix_rule) = prefix_rule {
args["prefix_rule"] = json!(prefix_rule);
}
let args_str = serde_json::to_string(&args)?;
Ok(ev_function_call(call_id, "shell_command", &args_str))
Ok(ev_function_call(call_id, "exec_command", &args_str))
}
fn exec_command_event(
@@ -1091,7 +1092,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
model_override: Some("gpt-5.2"),
outcome: Outcome::Auto,
expectation: Expectation::CommandFailure {
output_contains: "you should not ask for escalated permissions",
output_contains: "you cannot ask for escalated permissions",
},
},
ScenarioSpec {
@@ -1353,7 +1354,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
},
expectation: Expectation::FileNotCreated {
target: TargetPath::Workspace("ro_on_request_denied.txt"),
message_contains: &["exec command rejected by user"],
message_contains: &["rejected by user"],
},
},
ScenarioSpec {
@@ -1395,7 +1396,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
},
},
ScenarioSpec {
name: "apply_patch_shell_command_requires_patch_approval",
name: "apply_patch_exec_command_requires_patch_approval",
approval_policy: UnlessTrusted,
sandbox_policy: SandboxPolicy::DangerFullAccess,
action: ActionKind::ApplyPatchShell {
@@ -1489,7 +1490,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
},
},
ScenarioSpec {
name: "apply_patch_shell_command_outside_requires_patch_approval",
name: "apply_patch_exec_command_outside_requires_patch_approval",
approval_policy: OnRequest,
sandbox_policy: workspace_write(false),
action: ActionKind::ApplyPatchShell {
@@ -1547,46 +1548,6 @@ fn scenarios() -> Vec<ScenarioSpec> {
],
},
},
ScenarioSpec {
name: "read_only_unless_trusted_requires_approval",
approval_policy: UnlessTrusted,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::WriteFile {
target: TargetPath::Workspace("ro_unless_trusted.txt"),
content: "read-only-unless-trusted",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
model_override: Some("gpt-5.2"),
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Approved,
expected_reason: None,
},
expectation: Expectation::FileCreated {
target: TargetPath::Workspace("ro_unless_trusted.txt"),
content: "read-only-unless-trusted",
},
},
ScenarioSpec {
name: "read_only_unless_trusted_requires_approval_gpt_5_1_no_exit",
approval_policy: UnlessTrusted,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
action: ActionKind::WriteFile {
target: TargetPath::Workspace("ro_unless_trusted_5_1.txt"),
content: "read-only-unless-trusted",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
model_override: Some("gpt-5.4"),
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Approved,
expected_reason: None,
},
expectation: Expectation::FileCreatedNoExitCode {
target: TargetPath::Workspace("ro_unless_trusted_5_1.txt"),
content: "read-only-unless-trusted",
},
},
ScenarioSpec {
name: "read_only_never_reports_sandbox_failure",
approval_policy: Never,
@@ -1693,26 +1654,6 @@ fn scenarios() -> Vec<ScenarioSpec> {
body_contains: "workspace-network-ok",
},
},
ScenarioSpec {
name: "workspace_write_unless_trusted_requires_approval_outside_workspace",
approval_policy: UnlessTrusted,
sandbox_policy: workspace_write(false),
action: ActionKind::WriteFile {
target: TargetPath::OutsideWorkspace("ww_unless_trusted.txt"),
content: "workspace-unless-trusted",
},
sandbox_permissions: SandboxPermissions::UseDefault,
features: vec![],
model_override: Some("gpt-5.2"),
outcome: Outcome::ExecApproval {
decision: ReviewDecision::Approved,
expected_reason: None,
},
expectation: Expectation::FileCreated {
target: TargetPath::OutsideWorkspace("ww_unless_trusted.txt"),
content: "workspace-unless-trusted",
},
},
ScenarioSpec {
name: "workspace_write_never_blocks_outside_workspace",
approval_policy: Never,
@@ -2472,7 +2413,7 @@ async fn assert_execpolicy_amendment_context(
async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts() -> Result<()> {
let server = start_mock_server().await;
let approval_policy = AskForApproval::UnlessTrusted;
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let sandbox_policy = SandboxPolicy::new_workspace_write_policy();
let sandbox_policy_for_config = sandbox_policy.clone();
let mut builder = test_codex().with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
@@ -2645,7 +2586,7 @@ async fn spawned_subagent_execpolicy_amendment_propagates_to_parent_session() ->
let server = start_mock_server().await;
let approval_policy = AskForApproval::UnlessTrusted;
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let sandbox_policy = SandboxPolicy::new_workspace_write_policy();
let sandbox_policy_for_config = sandbox_policy.clone();
let mut builder = test_codex().with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
@@ -2688,8 +2629,8 @@ async fn spawned_subagent_execpolicy_amendment_propagates_to_parent_session() ->
.await;
let child_cmd_args = serde_json::to_string(&json!({
"command": "touch subagent-allow-prefix.txt",
"timeout_ms": 1_000,
"cmd": "touch subagent-allow-prefix.txt",
"yield_time_ms": 10_000,
"prefix_rule": ["touch", "subagent-allow-prefix.txt"],
}))?;
mount_sse_once_match(
@@ -2697,7 +2638,7 @@ async fn spawned_subagent_execpolicy_amendment_propagates_to_parent_session() ->
|req: &Request| body_contains(req, CHILD_PROMPT) && !body_contains(req, SPAWN_CALL_ID),
sse(vec![
ev_response_created("resp-child-1"),
ev_function_call(CHILD_CALL_ID_1, "shell_command", &child_cmd_args),
ev_function_call(CHILD_CALL_ID_1, "exec_command", &child_cmd_args),
ev_completed("resp-child-1"),
]),
)
@@ -2729,7 +2670,7 @@ async fn spawned_subagent_execpolicy_amendment_propagates_to_parent_session() ->
&server,
sse(vec![
ev_response_created("resp-parent-3"),
ev_function_call(PARENT_CALL_ID_2, "shell_command", &child_cmd_args),
ev_function_call(PARENT_CALL_ID_2, "exec_command", &child_cmd_args),
ev_completed("resp-parent-3"),
]),
)
@@ -3095,7 +3036,7 @@ async fn matched_prefix_rule_runs_unsandboxed_under_zsh_fork() -> Result<()> {
/// `:workspace` sandbox, while its inherited profile name remains `:workspace`.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[cfg(unix)]
async fn allowed_escalated_shell_command_inherits_active_permission_profile() -> Result<()> {
async fn allowed_escalated_exec_command_inherits_active_permission_profile() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
@@ -3477,7 +3418,7 @@ async fn approving_fallback_rule_for_compound_command_works() -> Result<()> {
let event = shell_event_with_prefix_rule(
call_id,
command,
/*timeout_ms*/ 1_000,
/*timeout_ms*/ 10_000,
SandboxPermissions::RequireEscalated,
Some(vec!["touch".to_string()]),
)?;
@@ -3524,7 +3465,7 @@ async fn approving_fallback_rule_for_compound_command_works() -> Result<()> {
let event = shell_event_with_prefix_rule(
call_id,
command,
/*timeout_ms*/ 1_000,
/*timeout_ms*/ 10_000,
SandboxPermissions::RequireEscalated,
Some(vec!["touch".to_string()]),
)?;
+1 -1
View File
@@ -501,7 +501,7 @@ fn remote_model_with_auto_review_override(slug: &str, review_model: &str) -> Mod
effort: ReasoningEffort::Medium,
description: ReasoningEffort::Medium.to_string(),
}],
shell_type: ConfigShellToolType::ShellCommand,
shell_type: ConfigShellToolType::UnifiedExec,
visibility: ModelVisibility::List,
supported_in_api: true,
input_modalities: default_input_modalities(),
+1 -1
View File
@@ -383,7 +383,7 @@ async fn missing_process_host_keeps_code_mode_only_and_fails_closed() -> Result<
assert!(
tools
.iter()
.all(|name| { !matches!(name.as_str(), "shell" | "shell_command" | "exec_command") }),
.all(|name| !matches!(name.as_str(), "shell" | "exec_command")),
"code-mode-only must never expose direct shell tools: {tools:?}"
);
let (output, _) = custom_tool_output_body_and_success(&request, "call-1");
+23 -12
View File
@@ -99,14 +99,24 @@ const REMOTE_V2_SUMMARY: &str = "global-instructions-remote-v2-summary";
pub(super) const COMPACT_WARNING_MESSAGE: &str = "Heads up: Long threads and multiple compactions can cause the model to be less accurate. Start a new thread when possible to keep threads small and targeted.";
fn ev_shell_command_call(call_id: &str, command: &str) -> serde_json::Value {
fn ev_exec_command_call(call_id: &str, command: &str) -> serde_json::Value {
ev_function_call(
call_id,
"shell_command",
&json!({ "command": command }).to_string(),
"exec_command",
&json!({ "cmd": command }).to_string(),
)
}
pub(super) fn allow_echo_commands(home: &Path) {
let rules_dir = home.join("rules");
fs::create_dir_all(&rules_dir).expect("create exec policy rules directory");
fs::write(
rules_dir.join("default.rules"),
r#"prefix_rule(pattern=["echo"], decision="allow")"#,
)
.expect("write echo exec policy rule");
}
fn disabled_permission_user_turn(
text: impl Into<String>,
cwd: PathBuf,
@@ -1042,6 +1052,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() {
let non_openai_provider_name = non_openai_model_provider(&server).name;
let test = test_codex()
.with_pre_build_hook(allow_echo_commands)
.with_config(move |config| {
config.model_provider.name = non_openai_provider_name;
})
@@ -1077,7 +1088,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() {
// first chunk of work
let model_reasoning_response_1_sse = sse(vec![
reasoning_response_1.clone(),
ev_shell_command_call("r1-shell", "echo make-react"),
ev_exec_command_call("r1-shell", "echo make-react"),
ev_completed_with_tokens("r1", token_count_used),
]);
@@ -1095,7 +1106,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() {
// second chunk of work
let model_reasoning_response_2_sse = sse(vec![
reasoning_response_2.clone(),
ev_shell_command_call("r3-shell", "echo make-node"),
ev_exec_command_call("r3-shell", "echo make-node"),
ev_completed_with_tokens("r3", token_count_used),
]);
@@ -1113,7 +1124,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() {
// third chunk of work
let model_reasoning_response_3_sse = sse(vec![
ev_reasoning_item("m6", &["I will create a python app"], &[]),
ev_shell_command_call("r6-shell", "echo make-python"),
ev_exec_command_call("r6-shell", "echo make-python"),
ev_completed_with_tokens("r6", token_count_used),
]);
@@ -1308,9 +1319,9 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() {
"type": "reasoning"
},
{
"arguments": "{\"command\":\"echo make-react\"}",
"arguments": "{\"cmd\":\"echo make-react\"}",
"call_id": "r1-shell",
"name": "shell_command",
"name": "exec_command",
"type": "function_call"
},
{
@@ -1408,9 +1419,9 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() {
"type": "reasoning"
},
{
"arguments": "{\"command\":\"echo make-node\"}",
"arguments": "{\"cmd\":\"echo make-node\"}",
"call_id": "r3-shell",
"name": "shell_command",
"name": "exec_command",
"type": "function_call"
},
{
@@ -1508,9 +1519,9 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() {
"type": "reasoning"
},
{
"arguments": "{\"command\":\"echo make-python\"}",
"arguments": "{\"cmd\":\"echo make-python\"}",
"call_id": "r6-shell",
"name": "shell_command",
"name": "exec_command",
"type": "function_call"
},
{
+23 -17
View File
@@ -1,3 +1,4 @@
use super::compact::allow_echo_commands;
use core_test_support::test_codex::local_selections;
use std::fs;
use std::path::Path;
@@ -1041,7 +1042,9 @@ async fn assert_remote_manual_compact_request_parity(
scenario: &str,
) -> Result<()> {
let uses_codex_backend = auth.uses_codex_backend();
let mut builder = test_codex().with_auth(auth);
let mut builder = test_codex()
.with_auth(auth)
.with_pre_build_hook(allow_echo_commands);
if let Some(service_tier) = configured_service_tier {
builder = builder.with_config(move |config| {
config.service_tier = Some(service_tier.request_value().to_string());
@@ -1078,8 +1081,8 @@ async fn assert_remote_manual_compact_request_parity(
responses::ev_completed("turn-three-final-response"),
]),
responses::sse(vec![
responses::ev_shell_command_call(
"turn-four-shell-command",
responses::ev_exec_command_call(
"turn-four-exec-command",
"echo TURN_FOUR_LOCAL_SHELL",
),
responses::ev_completed("turn-four-local-shell-response"),
@@ -2025,7 +2028,9 @@ async fn remote_compact_runs_automatically() -> Result<()> {
skip_if_no_network!(Ok(()));
let harness = TestCodexHarness::with_builder(
test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()),
test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_pre_build_hook(allow_echo_commands),
)
.await?;
let codex = harness.test().codex.clone();
@@ -2035,7 +2040,7 @@ async fn remote_compact_runs_automatically() -> Result<()> {
let initial_request = mount_sse_once(
harness.server(),
sse(vec![
responses::ev_shell_command_call("m1", "echo 'hi'"),
responses::ev_exec_command_call("m1", "echo 'hi'"),
responses::ev_completed_with_tokens("resp-1", /*total_tokens*/ 100000000), // over token limit
]),
)
@@ -2171,7 +2176,7 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R
harness.server(),
vec![
sse(vec![
responses::ev_shell_command_call(retained_call_id, retained_command),
responses::ev_exec_command_call(retained_call_id, retained_command),
responses::ev_completed("retained-call-response"),
]),
sse(vec![
@@ -2179,7 +2184,7 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R
responses::ev_completed("retained-final-response"),
]),
sse(vec![
responses::ev_shell_command_call(trimmed_call_id, trimmed_command),
responses::ev_exec_command_call(trimmed_call_id, trimmed_command),
responses::ev_completed("trimmed-call-response"),
]),
],
@@ -2286,7 +2291,7 @@ async fn remote_compact_rewrites_multiple_trailing_function_call_outputs() -> Re
harness.server(),
vec![
sse(vec![
responses::ev_shell_command_call(retained_call_id, retained_command),
responses::ev_exec_command_call(retained_call_id, retained_command),
responses::ev_completed("retained-call-response"),
]),
sse(vec![
@@ -2294,8 +2299,8 @@ async fn remote_compact_rewrites_multiple_trailing_function_call_outputs() -> Re
responses::ev_completed("retained-final-response"),
]),
sse(vec![
responses::ev_shell_command_call(first_trimmed_call_id, first_trimmed_command),
responses::ev_shell_command_call(second_trimmed_call_id, second_trimmed_command),
responses::ev_exec_command_call(first_trimmed_call_id, first_trimmed_command),
responses::ev_exec_command_call(second_trimmed_call_id, second_trimmed_command),
responses::ev_completed("parallel-call-response"),
]),
],
@@ -2391,7 +2396,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window()
harness.server(),
vec![
sse(vec![
responses::ev_shell_command_call(retained_call_id, retained_command),
responses::ev_exec_command_call(retained_call_id, retained_command),
responses::ev_completed_with_tokens(
"retained-call-response",
/*total_tokens*/ 100,
@@ -2402,7 +2407,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window()
responses::ev_completed("retained-final-response"),
]),
sse(vec![
responses::ev_shell_command_call(trimmed_call_id, trimmed_command),
responses::ev_exec_command_call(trimmed_call_id, trimmed_command),
responses::ev_completed_with_tokens(
"trimmed-call-response",
/*total_tokens*/ 100,
@@ -2715,7 +2720,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result
baseline_harness.server(),
vec![
sse(vec![
responses::ev_shell_command_call(baseline_retained_call_id, retained_command),
responses::ev_exec_command_call(baseline_retained_call_id, retained_command),
responses::ev_completed("baseline-retained-call-response"),
]),
sse(vec![
@@ -2723,7 +2728,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result
responses::ev_completed("baseline-retained-final-response"),
]),
sse(vec![
responses::ev_shell_command_call(baseline_trailing_call_id, trailing_command),
responses::ev_exec_command_call(baseline_trailing_call_id, trailing_command),
responses::ev_completed("baseline-trailing-call-response"),
]),
sse(vec![responses::ev_completed(
@@ -2811,7 +2816,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result
override_harness.server(),
vec![
sse(vec![
responses::ev_shell_command_call(override_retained_call_id, retained_command),
responses::ev_exec_command_call(override_retained_call_id, retained_command),
responses::ev_completed("override-retained-call-response"),
]),
sse(vec![
@@ -2819,7 +2824,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result
responses::ev_completed("override-retained-final-response"),
]),
sse(vec![
responses::ev_shell_command_call(override_trailing_call_id, trailing_command),
responses::ev_exec_command_call(override_trailing_call_id, trailing_command),
responses::ev_completed("override-trailing-call-response"),
]),
sse(vec![responses::ev_completed(
@@ -4558,6 +4563,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec
let harness = TestCodexHarness::with_builder(
test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_pre_build_hook(allow_echo_commands)
.with_config(|config| {
config.model_auto_compact_token_limit = Some(200);
}),
@@ -4576,7 +4582,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec
let second_turn_request_mock = responses::mount_sse_once(
harness.server(),
responses::sse(vec![
responses::ev_shell_command_call("call-remote-multi-summary", "echo multi-summary"),
responses::ev_exec_command_call("call-remote-multi-summary", "echo multi-summary"),
responses::ev_completed_with_tokens("r1", /*total_tokens*/ 1_000),
]),
)
@@ -1,3 +1,4 @@
use super::compact::allow_echo_commands;
use codex_core::TurnInputRequest;
use std::fs;
use std::path::Path;
@@ -514,6 +515,7 @@ async fn build_harness_inner(
fs::create_dir_all(FIXED_CWD)?;
let mut builder = test_codex()
.with_auth(settings.auth.build())
.with_pre_build_hook(allow_echo_commands)
.with_pre_build_hook(|home| {
fs::write(home.join("AGENTS.md"), USER_INSTRUCTIONS)
.expect("write global instructions");
@@ -690,7 +692,7 @@ fn response_bodies_for_step(scenario_name: &str, idx: usize, step: Step) -> Vec<
],
Step::ShellTool => vec![
responses::sse(vec![
responses::ev_shell_command_call(
responses::ev_exec_command_call(
&format!("{response_id}-shell-call"),
&format!("echo {scenario_name}_{idx}_SHELL_TOOL"),
),
@@ -948,6 +950,22 @@ fn normalize_string(value: &str) -> String {
normalize_tmp_prefix_before_marker(&mut text, "/skills/");
normalize_tmp_prefix_before_marker(&mut text, "\\skills\\");
let mut search_start = 0;
let chunk_id_prefix = "Chunk ID: ";
while let Some(relative_start) = text[search_start..].find(chunk_id_prefix) {
let value_start = search_start + relative_start + chunk_id_prefix.len();
let value_end = text[value_start..]
.find('\n')
.map_or(text.len(), |offset| value_start + offset);
let value = &text[value_start..value_end];
if !value.is_empty() && value.chars().all(|ch| ch.is_ascii_hexdigit()) {
text.replace_range(value_start..value_end, "<CHUNK_ID>");
search_start = value_start + "<CHUNK_ID>".len();
} else {
search_start = value_end;
}
}
let skills_open_tag = "<skills_instructions>";
let skills_close_tag = "</skills_instructions>";
let mut search_start = 0;
@@ -174,8 +174,8 @@ async fn current_time_reminders_follow_time_interval_and_persist_in_history() ->
let server = start_mock_server().await;
let tool_args = json!({
"command": "echo current time",
"timeout_ms": 1_000,
"cmd": "echo current time",
"yield_time_ms": 1_000,
});
let responses = mount_sse_sequence(
&server,
@@ -184,7 +184,7 @@ async fn current_time_reminders_follow_time_interval_and_persist_in_history() ->
ev_response_created("resp-1"),
ev_function_call(
"current-time-tool-call",
"shell_command",
"exec_command",
&serde_json::to_string(&tool_args)?,
),
ev_completed("resp-1"),
@@ -265,8 +265,8 @@ async fn current_time_reminders_can_follow_only_user_or_tool_outputs() -> Result
let server = start_mock_server().await;
let tool_args = json!({
"command": "echo current time",
"timeout_ms": 1_000,
"cmd": "echo current time",
"yield_time_ms": 1_000,
});
let mut continue_response = ev_completed("resp-2");
// Ask for another inference without recording a new user message or tool output.
@@ -278,7 +278,7 @@ async fn current_time_reminders_can_follow_only_user_or_tool_outputs() -> Result
ev_response_created("resp-1"),
ev_function_call(
"current-time-tool-call",
"shell_command",
"exec_command",
&serde_json::to_string(&tool_args)?,
),
ev_completed("resp-1"),
@@ -41,7 +41,6 @@ const SAVED_PREFIX: &str = r#"["git", "version"]"#;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum CommandTool {
ShellCommand,
UnifiedExec,
}
@@ -92,7 +91,6 @@ fn configure_saved_prefix_and_guardian(config: &mut Config) {
fn command_response(response_id: &str, call_id: &str, command_tool: CommandTool) -> Result<String> {
let (tool_name, command_key) = match command_tool {
CommandTool::ShellCommand => ("shell_command", "command"),
CommandTool::UnifiedExec => ("exec_command", "cmd"),
};
let mut args = json!({
@@ -130,11 +128,8 @@ async fn submit_model_turn(test: &TestCodex, model: &str, prompt: &str) -> Resul
test.submit_text_turn(prompt).await
}
#[test_case(CommandTool::ShellCommand, ModelSpecialty::Cyber, ShellBackend::Standard; "cyber shell command is reviewed")]
#[test_case(CommandTool::UnifiedExec, ModelSpecialty::Cyber, ShellBackend::Standard; "cyber unified exec is reviewed")]
#[test_case(CommandTool::ShellCommand, ModelSpecialty::Cyber, ShellBackend::ZshFork; "cyber zsh shell command is reviewed")]
#[test_case(CommandTool::UnifiedExec, ModelSpecialty::Cyber, ShellBackend::ZshFork; "cyber zsh unified exec is reviewed")]
#[test_case(CommandTool::ShellCommand, ModelSpecialty::General, ShellBackend::Standard; "general shell command keeps saved approval")]
#[test_case(CommandTool::UnifiedExec, ModelSpecialty::General, ShellBackend::Standard; "general unified exec keeps saved approval")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn saved_prefix_only_bypasses_guardian_for_general_models(
@@ -222,7 +217,6 @@ async fn saved_prefix_only_bypasses_guardian_for_general_models(
Ok(())
}
#[test_case(CommandTool::ShellCommand; "shell command")]
#[test_case(CommandTool::UnifiedExec; "unified exec")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cyber_model_user_approval_never_offers_a_reusable_prefix(
@@ -331,20 +325,20 @@ async fn switching_models_suppresses_and_restores_saved_prefix_approvals() -> Re
command_response(
"parent-general-first-command",
"general-first-command",
CommandTool::ShellCommand,
CommandTool::UnifiedExec,
)?,
sse_completed("parent-general-first-complete"),
command_response(
"parent-cyber-command",
"cyber-command",
CommandTool::ShellCommand,
CommandTool::UnifiedExec,
)?,
guardian_allow_response("guardian-cyber-review"),
sse_completed("parent-cyber-complete"),
command_response(
"parent-general-last-command",
"general-last-command",
CommandTool::ShellCommand,
CommandTool::UnifiedExec,
)?,
sse_completed("parent-general-last-complete"),
],
+19 -134
View File
@@ -246,15 +246,15 @@ async fn granular_complex_forced_rm_denial_explains_why_the_command_was_rejected
let test = builder.build_with_auto_env(&server).await?;
let call_id = "forced-rm-denied";
let args = json!({
"command": COMPLEX_FORCED_RM_COMMAND,
"timeout_ms": 1_000,
"cmd": COMPLEX_FORCED_RM_COMMAND,
"yield_time_ms": 1_000,
});
mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-forced-rm-1"),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-forced-rm-1"),
]),
)
@@ -310,15 +310,15 @@ async fn granular_complex_forced_rm_requests_approval_when_allowed() -> Result<(
let test = builder.build_with_auto_env(&server).await?;
let call_id = "forced-rm-approval";
let args = json!({
"command": COMPLEX_FORCED_RM_COMMAND,
"timeout_ms": 1_000,
"cmd": COMPLEX_FORCED_RM_COMMAND,
"yield_time_ms": 1_000,
});
mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-forced-rm-approval-1"),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-forced-rm-approval-1"),
]),
)
@@ -389,15 +389,15 @@ async fn deeply_nested_forced_rm_is_rejected_before_execution_when_approvals_are
fs::write(&sentinel, "must not be deleted")?;
let call_id = "deeply-nested-forced-rm";
let args = json!({
"command": "env env env env env env env env env rm -rf forced-rm-sentinel",
"timeout_ms": 1_000,
"cmd": "env env env env env env env env env rm -rf forced-rm-sentinel",
"yield_time_ms": 1_000,
});
mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-deeply-nested-forced-rm-1"),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-deeply-nested-forced-rm-1"),
]),
)
@@ -532,20 +532,20 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> {
let call_id = "shell-forbidden";
let args = json!({
"command": "echo blocked",
"timeout_ms": 1_000,
"cmd": "echo blocked",
"yield_time_ms": 1_000,
});
mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
)
.await;
mount_sse_once(
let results_mock = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "done"),
@@ -581,23 +581,18 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> {
)
.await?;
let EventMsg::ExecCommandEnd(end) = wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::ExecCommandEnd(_))
})
.await
else {
unreachable!()
};
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let output = results_mock
.single_request()
.function_call_output_text(call_id)
.expect("forbidden command should produce a tool response");
assert!(
end.aggregated_output
.contains("policy forbids commands starting with `echo`"),
"unexpected output: {}",
end.aggregated_output
output.contains("policy forbids commands starting with `echo`"),
"unexpected output: {output}"
);
Ok(())
@@ -908,61 +903,6 @@ async fn environment_command_policy_changes_invalidate_session_approvals() -> Re
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_command_empty_script_with_collaboration_mode_does_not_panic() -> Result<()> {
let server = start_mock_server().await;
let mut builder = test_codex().with_model("gpt-5.2").with_config(|config| {
config
.features
.enable(Feature::CollaborationModes)
.expect("test config should allow feature update");
});
let test = builder.build(&server).await?;
let call_id = "shell-empty-script-collab";
let args = json!({
"command": "",
"timeout_ms": 1_000,
});
mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-empty-shell-1"),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_completed("resp-empty-shell-1"),
]),
)
.await;
let results_mock = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-empty-shell-1", "done"),
ev_completed("resp-empty-shell-2"),
]),
)
.await;
let collaboration_mode = collaboration_mode_for_model(test.session_configured.model.clone());
submit_user_turn(
&test,
"run an empty shell command",
AskForApproval::OnRequest,
PermissionProfile::Disabled,
Some(collaboration_mode),
)
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let output_item = results_mock.single_request().function_call_output(call_id);
assert_no_matched_rules_invariant(&output_item);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unified_exec_empty_script_with_collaboration_mode_does_not_panic() -> Result<()> {
let server = start_mock_server().await;
@@ -1022,61 +962,6 @@ async fn unified_exec_empty_script_with_collaboration_mode_does_not_panic() -> R
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_command_whitespace_script_with_collaboration_mode_does_not_panic() -> Result<()> {
let server = start_mock_server().await;
let mut builder = test_codex().with_model("gpt-5.2").with_config(|config| {
config
.features
.enable(Feature::CollaborationModes)
.expect("test config should allow feature update");
});
let test = builder.build(&server).await?;
let call_id = "shell-whitespace-script-collab";
let args = json!({
"command": " \n\t ",
"timeout_ms": 1_000,
});
mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-whitespace-shell-1"),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_completed("resp-whitespace-shell-1"),
]),
)
.await;
let results_mock = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-whitespace-shell-1", "done"),
ev_completed("resp-whitespace-shell-2"),
]),
)
.await;
let collaboration_mode = collaboration_mode_for_model(test.session_configured.model.clone());
submit_user_turn(
&test,
"run whitespace shell command",
AskForApproval::OnRequest,
PermissionProfile::Disabled,
Some(collaboration_mode),
)
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let output_item = results_mock.single_request().function_call_output(call_id);
assert_no_matched_rules_invariant(&output_item);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unified_exec_whitespace_script_with_collaboration_mode_does_not_panic() -> Result<()> {
let server = start_mock_server().await;
+57 -69
View File
@@ -1611,9 +1611,9 @@ async fn async_hook_context_is_injected_into_the_active_turn() -> Result<()> {
let server = start_mock_server().await;
let gate = TempDir::new()?;
let release_path = gate.path().join("release");
let call_id = "async-hook-context-gated-shell-command";
let call_id = "async-hook-context-gated-exec-command";
let args = serde_json::json!({
"command": format!(
"cmd": format!(
r#"python3 -c 'import time; from pathlib import Path; gate = Path(r"{}"); exec("while not gate.exists(): time.sleep(0.01)")'"#,
release_path.display()
)
@@ -1623,7 +1623,7 @@ async fn async_hook_context_is_injected_into_the_active_turn() -> Result<()> {
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
sse(vec![
@@ -1946,9 +1946,9 @@ async fn pre_tool_use_hook_spills_large_additional_context() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "pretooluse-shell-command-large-context";
let call_id = "pretooluse-exec-command-large-context";
let command = "printf pre-tool-output".to_string();
let args = serde_json::json!({ "command": command });
let args = serde_json::json!({ "cmd": command });
let responses = mount_sse_sequence(
&server,
vec![
@@ -1956,7 +1956,7 @@ async fn pre_tool_use_hook_spills_large_additional_context() -> Result<()> {
ev_response_created("resp-1"),
core_test_support::responses::ev_function_call(
call_id,
"shell_command",
"exec_command",
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
@@ -2763,14 +2763,14 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu
}
#[tokio::test]
async fn permission_request_hook_allows_shell_command_without_user_approval() -> Result<()> {
async fn permission_request_hook_allows_exec_command_without_user_approval() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "permissionrequest-shell-command";
let marker = std::env::temp_dir().join("permissionrequest-shell-command-marker");
let call_id = "permissionrequest-exec-command";
let marker = std::env::temp_dir().join("permissionrequest-exec-command-marker");
let command = format!("rm -f {}", marker.display());
let args = serde_json::json!({ "command": command });
let args = serde_json::json!({ "cmd": command });
let responses = mount_sse_sequence(
&server,
vec![
@@ -2778,7 +2778,7 @@ async fn permission_request_hook_allows_shell_command_without_user_approval() ->
ev_response_created("resp-1"),
core_test_support::responses::ev_function_call(
call_id,
"shell_command",
"exec_command",
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
@@ -2845,8 +2845,8 @@ async fn permission_request_hook_allow_bypasses_strict_auto_review() -> Result<(
let server = start_mock_server().await;
let permission_call_id = "strict-hook-permissions";
let command_call_id = "strict-hook-shell-command";
let marker_name = "strict-hook-shell-command-marker";
let command_call_id = "strict-hook-exec-command";
let marker_name = "strict-hook-exec-command-marker";
let command = match test_target_os() {
TestTargetOs::Linux | TestTargetOs::MacOs => format!("rm -f {marker_name}"),
TestTargetOs::Windows => {
@@ -2863,7 +2863,7 @@ async fn permission_request_hook_allow_bypasses_strict_auto_review() -> Result<(
"reason": "Enable strict auto review",
"permissions": requested_permissions,
});
let command_args = serde_json::json!({ "command": command });
let command_args = serde_json::json!({ "cmd": command });
let responses = mount_sse_sequence(
&server,
vec![
@@ -2880,7 +2880,7 @@ async fn permission_request_hook_allow_bypasses_strict_auto_review() -> Result<(
ev_response_created("resp-strict-hook-2"),
ev_function_call(
command_call_id,
"shell_command",
"exec_command",
&serde_json::to_string(&command_args)?,
),
ev_completed("resp-strict-hook-2"),
@@ -3183,13 +3183,13 @@ mode = "limited"
allow_local_binding = true
"#,
)?;
let args = serde_json::json!({ "command": command });
let args = serde_json::json!({ "cmd": command });
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-network-hook-1"),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-network-hook-1"),
]),
sse(vec![
@@ -3297,15 +3297,15 @@ allow_local_binding = true
}
#[tokio::test]
async fn pre_tool_use_blocks_shell_command_before_execution() -> Result<()> {
async fn pre_tool_use_json_deny_blocks_exec_command_before_execution() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "pretooluse-shell-command";
let call_id = "pretooluse-exec-command";
let marker_dir = TempDir::new()?;
let marker = marker_dir.path().join("pretooluse-shell-command-marker");
let marker = marker_dir.path().join("pretooluse-exec-command-marker");
let command = format!("git init --quiet {}", marker.display());
let args = serde_json::json!({ "command": command });
let args = serde_json::json!({ "cmd": command });
let responses = mount_sse_sequence(
&server,
vec![
@@ -3313,7 +3313,7 @@ async fn pre_tool_use_blocks_shell_command_before_execution() -> Result<()> {
ev_response_created("resp-1"),
core_test_support::responses::ev_function_call(
call_id,
"shell_command",
"exec_command",
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
@@ -3388,13 +3388,13 @@ async fn pre_tool_use_blocks_shell_command_before_execution() -> Result<()> {
}
#[tokio::test]
async fn pre_tool_use_records_additional_context_for_shell_command() -> Result<()> {
async fn pre_tool_use_records_additional_context_for_exec_command() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "pretooluse-shell-command-context";
let call_id = "pretooluse-exec-command-context";
let command = "printf pre-tool-output".to_string();
let args = serde_json::json!({ "command": command });
let args = serde_json::json!({ "cmd": command });
let responses = mount_sse_sequence(
&server,
vec![
@@ -3402,7 +3402,7 @@ async fn pre_tool_use_records_additional_context_for_shell_command() -> Result<(
ev_response_created("resp-1"),
core_test_support::responses::ev_function_call(
call_id,
"shell_command",
"exec_command",
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
@@ -3450,14 +3450,14 @@ async fn pre_tool_use_records_additional_context_for_shell_command() -> Result<(
}
#[tokio::test]
async fn blocked_pre_tool_use_records_additional_context_for_shell_command() -> Result<()> {
async fn blocked_pre_tool_use_records_additional_context_for_exec_command() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "pretooluse-shell-command-blocked-context";
let marker = std::env::temp_dir().join("pretooluse-shell-command-blocked-context-marker");
let call_id = "pretooluse-exec-command-blocked-context";
let marker = std::env::temp_dir().join("pretooluse-exec-command-blocked-context-marker");
let command = format!("printf blocked > {}", marker.display());
let args = serde_json::json!({ "command": command });
let args = serde_json::json!({ "cmd": command });
let responses = mount_sse_sequence(
&server,
vec![
@@ -3465,7 +3465,7 @@ async fn blocked_pre_tool_use_records_additional_context_for_shell_command() ->
ev_response_created("resp-1"),
core_test_support::responses::ev_function_call(
call_id,
"shell_command",
"exec_command",
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
@@ -3534,19 +3534,19 @@ async fn async_pre_tool_use_cannot_block_or_rewrite_and_still_records_additional
let hook_finished_path_for_fixture = hook_finished_path.clone();
let original_marker = gate.path().join("original");
let rewritten_marker = gate.path().join("rewritten");
let call_id = "async-pretooluse-shell-command";
let call_id = "async-pretooluse-exec-command";
let original_command = format!(
r#"python3 -c 'import time; from pathlib import Path; gate = Path(r"{}"); exec("while not gate.exists(): time.sleep(0.01)"); Path(r"{}").write_text("original"); print("original-output")'"#,
release_path.display(),
original_marker.display()
);
let args = serde_json::json!({ "command": original_command });
let args = serde_json::json!({ "cmd": original_command });
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
sse(vec![
@@ -3687,14 +3687,12 @@ Path(r"{hook_finished_path}").write_text("finished", encoding="utf-8")
#[derive(Clone, Copy)]
enum BashRewriteSurface {
ExecCommand,
ShellCommand,
}
impl BashRewriteSurface {
fn slug(self) -> &'static str {
match self {
BashRewriteSurface::ExecCommand => "exec-command",
BashRewriteSurface::ShellCommand => "shell-command",
}
}
@@ -3705,17 +3703,12 @@ impl BashRewriteSurface {
"exec_command",
&serde_json::to_string(&serde_json::json!({ "cmd": command_text }))?,
)),
BashRewriteSurface::ShellCommand => Ok(ev_function_call(
call_id,
"shell_command",
&serde_json::to_string(&serde_json::json!({ "command": command_text }))?,
)),
}
}
fn original_command(self, marker: &Path) -> String {
match self {
BashRewriteSurface::ExecCommand | BashRewriteSurface::ShellCommand => {
BashRewriteSurface::ExecCommand => {
format!("git init --quiet {}", marker.display())
}
}
@@ -3723,7 +3716,7 @@ impl BashRewriteSurface {
fn rewritten_command(self, marker: &Path) -> String {
match self {
BashRewriteSurface::ExecCommand | BashRewriteSurface::ShellCommand => {
BashRewriteSurface::ExecCommand => {
format!("git init {}", marker.display())
}
}
@@ -3803,11 +3796,6 @@ async fn assert_pre_tool_use_rewrites_bash_surface(surface: BashRewriteSurface)
Ok(())
}
#[tokio::test]
async fn pre_tool_use_rewrites_shell_command_before_execution() -> Result<()> {
assert_pre_tool_use_rewrites_bash_surface(BashRewriteSurface::ShellCommand).await
}
#[tokio::test]
async fn pre_tool_use_rewrites_exec_command_before_execution() -> Result<()> {
assert_pre_tool_use_rewrites_bash_surface(BashRewriteSurface::ExecCommand).await
@@ -4074,14 +4062,14 @@ async fn post_tool_use_exit_two_rejects_code_mode_tool_promise() -> Result<()> {
}
#[tokio::test]
async fn plugin_pre_tool_use_blocks_shell_command_before_execution() -> Result<()> {
async fn plugin_pre_tool_use_blocks_exec_command_before_execution() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "plugin-pretooluse-shell-command";
let marker = std::env::temp_dir().join("plugin-pretooluse-shell-command-marker");
let call_id = "plugin-pretooluse-exec-command";
let marker = std::env::temp_dir().join("plugin-pretooluse-exec-command-marker");
let command = format!("printf blocked > {}", marker.display());
let args = serde_json::json!({ "command": command });
let args = serde_json::json!({ "cmd": command });
let responses = mount_sse_sequence(
&server,
vec![
@@ -4089,7 +4077,7 @@ async fn plugin_pre_tool_use_blocks_shell_command_before_execution() -> Result<(
ev_response_created("resp-1"),
core_test_support::responses::ev_function_call(
call_id,
"shell_command",
"exec_command",
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
@@ -4232,7 +4220,7 @@ async fn pre_tool_use_blocks_shell_when_defined_in_config_toml() -> Result<()> {
let call_id = "pretooluse-config-toml";
let marker = std::env::temp_dir().join("pretooluse-config-toml-marker");
let command = format!("printf blocked > {}", marker.display());
let args = serde_json::json!({ "command": command });
let args = serde_json::json!({ "cmd": command });
let responses = mount_sse_sequence(
&server,
vec![
@@ -4240,7 +4228,7 @@ async fn pre_tool_use_blocks_shell_when_defined_in_config_toml() -> Result<()> {
ev_response_created("resp-1"),
core_test_support::responses::ev_function_call(
call_id,
"shell_command",
"exec_command",
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
@@ -4315,7 +4303,7 @@ async fn pre_tool_use_merges_hooks_json_and_config_toml() -> Result<()> {
let server = start_mock_server().await;
let call_id = "pretooluse-merged-sources";
let command = "printf merged-hooks".to_string();
let args = serde_json::json!({ "command": command });
let args = serde_json::json!({ "cmd": command });
let responses = mount_sse_sequence(
&server,
vec![
@@ -4323,7 +4311,7 @@ async fn pre_tool_use_merges_hooks_json_and_config_toml() -> Result<()> {
ev_response_created("resp-1"),
core_test_support::responses::ev_function_call(
call_id,
"shell_command",
"exec_command",
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
@@ -4824,13 +4812,13 @@ async fn pre_tool_use_rewrites_local_function_tool_before_execution() -> Result<
}
#[tokio::test]
async fn post_tool_use_records_additional_context_for_shell_command() -> Result<()> {
async fn post_tool_use_records_additional_context_for_exec_command() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "posttooluse-shell-command";
let call_id = "posttooluse-exec-command";
let command = "printf post-tool-output".to_string();
let args = serde_json::json!({ "command": command });
let args = serde_json::json!({ "cmd": command });
let responses = mount_sse_sequence(
&server,
vec![
@@ -4838,7 +4826,7 @@ async fn post_tool_use_records_additional_context_for_shell_command() -> Result<
ev_response_created("resp-1"),
core_test_support::responses::ev_function_call(
call_id,
"shell_command",
"exec_command",
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
@@ -4913,13 +4901,13 @@ async fn post_tool_use_records_additional_context_for_shell_command() -> Result<
}
#[tokio::test]
async fn post_tool_use_block_decision_replaces_shell_command_output_with_reason() -> Result<()> {
async fn post_tool_use_block_decision_replaces_exec_command_output_with_reason() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "posttooluse-shell-command-block";
let call_id = "posttooluse-exec-command-block";
let command = "printf blocked-output".to_string();
let args = serde_json::json!({ "command": command });
let args = serde_json::json!({ "cmd": command });
let responses = mount_sse_sequence(
&server,
vec![
@@ -4927,7 +4915,7 @@ async fn post_tool_use_block_decision_replaces_shell_command_output_with_reason(
ev_response_created("resp-1"),
core_test_support::responses::ev_function_call(
call_id,
"shell_command",
"exec_command",
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
@@ -4973,14 +4961,14 @@ async fn post_tool_use_block_decision_replaces_shell_command_output_with_reason(
}
#[tokio::test]
async fn post_tool_use_continue_false_replaces_shell_command_output_with_stop_reason() -> Result<()>
async fn post_tool_use_continue_false_replaces_exec_command_output_with_stop_reason() -> Result<()>
{
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "posttooluse-shell-command-stop";
let call_id = "posttooluse-exec-command-stop";
let command = "printf stop-output".to_string();
let args = serde_json::json!({ "command": command });
let args = serde_json::json!({ "cmd": command });
let responses = mount_sse_sequence(
&server,
vec![
@@ -4988,7 +4976,7 @@ async fn post_tool_use_continue_false_replaces_shell_command_output_with_stop_re
ev_response_created("resp-1"),
core_test_support::responses::ev_function_call(
call_id,
"shell_command",
"exec_command",
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
+1 -2
View File
@@ -40,6 +40,7 @@ mod agent_execution;
mod agent_websocket;
mod agents_md;
mod apply_patch_cli;
mod apply_patch_serialization;
#[cfg(not(target_os = "windows"))]
mod approvals;
mod audio_truncation;
@@ -134,8 +135,6 @@ mod safety_buffering;
mod safety_check_downgrade;
mod search_tool;
mod send_user_message_async;
mod shell_command;
mod shell_serialization;
mod shell_snapshot;
mod skill_approval;
mod skills;
+2 -2
View File
@@ -115,7 +115,7 @@ fn test_model_info(
effort: ReasoningEffort::Medium,
description: ReasoningEffort::Medium.to_string(),
}],
shell_type: ConfigShellToolType::ShellCommand,
shell_type: ConfigShellToolType::UnifiedExec,
visibility: ModelVisibility::List,
supported_in_api: true,
input_modalities,
@@ -1330,7 +1330,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result<
effort: ReasoningEffort::Medium,
description: ReasoningEffort::Medium.to_string(),
}],
shell_type: ConfigShellToolType::ShellCommand,
shell_type: ConfigShellToolType::UnifiedExec,
visibility: ModelVisibility::List,
supported_in_api: true,
input_modalities: default_input_modalities(),
@@ -495,7 +495,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo {
description: "medium".to_string(),
},
],
shell_type: ConfigShellToolType::ShellCommand,
shell_type: ConfigShellToolType::UnifiedExec,
visibility: ModelVisibility::List,
supported_in_api: true,
priority,
@@ -21,8 +21,8 @@ use core_test_support::TempDirExt;
use core_test_support::responses;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_exec_command_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::ev_shell_command_call;
use core_test_support::responses::sse;
use core_test_support::responses::sse_response;
use core_test_support::skip_if_no_network;
@@ -38,7 +38,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch
const ETAG_1: &str = "\"models-etag-1\"";
const ETAG_2: &str = "\"models-etag-2\"";
const CALL_ID: &str = "shell-command-call-1";
const CALL_ID: &str = "exec-command-call-1";
let server = MockServer::start().await;
@@ -87,7 +87,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch
// It also includes a mismatched X-Models-Etag, which should trigger a /models refresh.
let first_response_body = sse(vec![
ev_response_created("resp-1"),
ev_shell_command_call(CALL_ID, "/bin/echo 'etag ok'"),
ev_exec_command_call(CALL_ID, "/bin/echo 'etag ok'"),
ev_completed("resp-1"),
]);
responses::mount_response_once(
+23 -23
View File
@@ -80,9 +80,9 @@ fn assert_empty_mcp_tool_fields(line: &str) -> Result<(), String> {
Ok(())
}
fn shell_command_call(call_id: &str, command: &str) -> serde_json::Value {
let args = serde_json::json!({ "command": command }).to_string();
ev_function_call(call_id, "shell_command", &args)
fn exec_command_call(call_id: &str, command: &str) -> serde_json::Value {
let args = serde_json::json!({ "cmd": command }).to_string();
ev_function_call(call_id, "exec_command", &args)
}
fn touch_command(path: &str) -> String {
@@ -989,13 +989,13 @@ async fn handle_response_item_records_tool_result_for_function_call() {
#[tokio::test]
#[traced_test]
async fn handle_response_item_records_tool_result_for_shell_command_call() {
async fn handle_response_item_records_tool_result_for_exec_command_call() {
let server = start_mock_server().await;
mount_sse_once(
&server,
sse(vec![
shell_command_call("shell-call", "echo shell"),
exec_command_call("shell-call", "echo shell"),
ev_completed("done"),
]),
)
@@ -1038,10 +1038,10 @@ async fn handle_response_item_records_tool_result_for_shell_command_call() {
.find(|line| line.contains("codex.tool_result") && line.contains("call_id=shell-call"))
.ok_or_else(|| "missing codex.tool_result event".to_string())?;
if !line.contains("tool_name=shell_command") {
if !line.contains("tool_name=exec_command") {
return Err("missing tool_name field".to_string());
}
if !line.contains("arguments={\"command\":\"echo shell\"}") {
if !line.contains("arguments={\"cmd\":\"echo shell\"}") {
return Err("missing arguments field".to_string());
}
let output_idx = line
@@ -1077,8 +1077,8 @@ fn tool_decision_assertion<'a>(
.ok_or_else(|| format!("missing codex.tool_decision event for {call_id}"))?;
let lower = line.to_lowercase();
if !lower.contains("tool_name=shell_command") {
return Err("missing tool_name for shell_command".to_string());
if !lower.contains("tool_name=exec_command") {
return Err("missing tool_name for exec_command".to_string());
}
if !lower.contains(&format!("decision={expected_decision}")) {
return Err(format!("unexpected decision for {call_id}"));
@@ -1108,8 +1108,8 @@ fn sandbox_outcome_assertion<'a>(
.ok_or_else(|| format!("missing codex.sandbox_outcome event for {call_id}"))?;
let lower = line.to_lowercase();
if !lower.contains("tool_name=shell_command") {
return Err("missing tool_name for shell_command".to_string());
if !lower.contains("tool_name=exec_command") {
return Err("missing tool_name for exec_command".to_string());
}
if !lower.contains(&format!("outcome={expected_outcome}")) {
return Err(format!("unexpected sandbox outcome for {call_id}"));
@@ -1209,7 +1209,7 @@ fn sandbox_outcome_event_records_outcome() {
);
telemetry.sandbox_outcome(
"shell_command",
"exec_command",
"sandbox-outcome-call",
"escalated",
Duration::from_millis(/*millis*/ 12),
@@ -1224,12 +1224,12 @@ fn sandbox_outcome_event_records_outcome() {
#[tokio::test]
#[traced_test]
async fn handle_shell_command_autoapprove_from_config_records_tool_decision() {
async fn handle_exec_command_autoapprove_from_config_records_tool_decision() {
let server = start_mock_server().await;
mount_sse_once(
&server,
sse(vec![
shell_command_call("auto_config_call", "echo local shell"),
exec_command_call("auto_config_call", "echo local shell"),
ev_completed("done"),
]),
)
@@ -1275,13 +1275,13 @@ async fn handle_shell_command_autoapprove_from_config_records_tool_decision() {
#[tokio::test]
#[traced_test]
async fn handle_shell_command_user_approved_records_tool_decision() {
async fn handle_exec_command_user_approved_records_tool_decision() {
let server = start_mock_server().await;
let command = touch_command("codex-otel-approval-test");
mount_sse_once(
&server,
sse(vec![
shell_command_call("user_approved_call", &command),
exec_command_call("user_approved_call", &command),
ev_completed("done"),
]),
)
@@ -1339,14 +1339,14 @@ async fn handle_shell_command_user_approved_records_tool_decision() {
#[tokio::test]
#[traced_test]
async fn handle_shell_command_user_approved_for_session_records_tool_decision() {
async fn handle_exec_command_user_approved_for_session_records_tool_decision() {
let server = start_mock_server().await;
let command = touch_command("codex-otel-approval-test");
mount_sse_once(
&server,
sse(vec![
shell_command_call("user_approved_session_call", &command),
exec_command_call("user_approved_session_call", &command),
ev_completed("done"),
]),
)
@@ -1410,7 +1410,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() {
mount_sse_once(
&server,
sse(vec![
shell_command_call("sandbox_retry_call", &command),
exec_command_call("sandbox_retry_call", &command),
ev_completed("done"),
]),
)
@@ -1467,14 +1467,14 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() {
#[tokio::test]
#[traced_test]
async fn handle_shell_command_user_denies_records_tool_decision() {
async fn handle_exec_command_user_denies_records_tool_decision() {
let server = start_mock_server().await;
let command = touch_command("codex-otel-approval-test");
mount_sse_once(
&server,
sse(vec![
shell_command_call("user_denied_call", &command),
exec_command_call("user_denied_call", &command),
ev_completed("done"),
]),
)
@@ -1538,7 +1538,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision()
mount_sse_once(
&server,
sse(vec![
shell_command_call("sandbox_session_call", &command),
exec_command_call("sandbox_session_call", &command),
ev_completed("done"),
]),
)
@@ -1602,7 +1602,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() {
mount_sse_once(
&server,
sse(vec![
shell_command_call("sandbox_deny_call", &command),
exec_command_call("sandbox_deny_call", &command),
ev_completed("done"),
]),
)
+3 -3
View File
@@ -1263,9 +1263,9 @@ async fn steered_user_input_waits_when_tool_output_triggers_compact_before_next_
"printf '%04000d' 0"
};
let large_output_args = json!({
"command": large_output_command,
"cmd": large_output_command,
"login": false,
"timeout_ms": 2000,
"yield_time_ms": 2000,
})
.to_string();
@@ -1273,7 +1273,7 @@ async fn steered_user_input_waits_when_tool_output_triggers_compact_before_next_
chunk(ev_response_created("resp-1")),
chunk(ev_function_call(
"call-1",
"shell_command",
"exec_command",
&large_output_args,
)),
gated_chunk(
+4 -4
View File
@@ -388,7 +388,7 @@ async fn persisted_remote_plugin_command_attribution_flows_through_turn_context(
let command = shlex::try_join(["/bin/sh", script_path.to_string_lossy().as_ref()])?;
let call_id = "remote-plugin-command";
let arguments = serde_json::to_string(&serde_json::json!({
"command": command,
"cmd": command,
"login": false,
}))?;
mount_sse_sequence(
@@ -396,7 +396,7 @@ async fn persisted_remote_plugin_command_attribution_flows_through_turn_context(
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell_command", &arguments),
ev_function_call(call_id, "exec_command", &arguments),
ev_completed("resp-1"),
]),
sse(vec![
@@ -1450,7 +1450,7 @@ async fn implicit_plugin_skill_invocation_tracks_remote_plugin_id(
}
};
let command_args = serde_json::json!({
"command": command,
"cmd": command,
"login": false,
})
.to_string();
@@ -1459,7 +1459,7 @@ async fn implicit_plugin_skill_invocation_tracks_remote_plugin_id(
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call("call-1", "shell_command", &command_args),
ev_function_call("call-1", "exec_command", &command_args),
ev_completed("resp-1"),
]),
sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]),
+2 -3
View File
@@ -347,7 +347,7 @@ async fn remote_test_env_exposes_target_shell_to_model() -> Result<()> {
let mut builder = test_codex().with_config(|config| {
config
.features
.disable(Feature::UnifiedExec)
.disable(Feature::ShellTool)
.expect("test config should allow feature update");
});
let test = builder.build_with_auto_env(&server).await?;
@@ -356,7 +356,7 @@ async fn remote_test_env_exposes_target_shell_to_model() -> Result<()> {
let request = response_mock.single_request();
let tools = tool_names(&request.body_json());
assert!(!tools.contains(&"shell_command".to_string()));
assert!(!tools.contains(&"exec_command".to_string()));
let environment_context = request
.message_input_texts("user")
.into_iter()
@@ -2793,7 +2793,6 @@ async fn exec_command_routing_output(
let tools = tool_names(&request.body_json());
assert!(tools.contains(&"exec_command".to_string()));
assert!(tools.contains(&"write_stdin".to_string()));
assert!(!tools.contains(&"shell_command".to_string()));
Ok(output)
}
+46 -22
View File
@@ -76,10 +76,20 @@ async fn unknown_model_sends_builtin_instructions() -> Result<()> {
test.submit_turn("use fallback model metadata").await?;
assert_eq!(
response.single_request().instructions_text(),
BASE_INSTRUCTIONS
);
let request = response.single_request();
assert_eq!(request.instructions_text(), BASE_INSTRUCTIONS);
let body = request.body_json();
let tools = body["tools"]
.as_array()
.expect("fallback model tools should be present");
for tool_name in ["exec_command", "write_stdin"] {
assert!(
tools
.iter()
.any(|tool| tool["name"].as_str() == Some(tool_name)),
"fallback model should expose {tool_name}: {tools:?}"
);
}
Ok(())
}
@@ -534,13 +544,17 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
experimental_supported_tools: Vec::new(),
};
let models_mock = mount_models_once(
&server,
ModelsResponse {
models: vec![remote_model],
},
)
.await;
let mut models_response = serde_json::to_value(ModelsResponse {
models: vec![remote_model],
})?;
models_response["models"][0]["shell_type"] = json!("shell_command");
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(models_response))
.up_to_n_times(1)
.expect(1)
.mount(&server)
.await;
let mut builder = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
@@ -560,14 +574,6 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
assert_eq!(available_model.model, REMOTE_MODEL_SLUG);
let requests = models_mock.requests();
assert_eq!(
requests.len(),
1,
"expected a single /models refresh request for the remote models feature"
);
assert_eq!(requests[0].url.path(), "/v1/models");
let model_info = models_manager
.get_model_info(REMOTE_MODEL_SLUG, &config.to_models_manager_config())
.await;
@@ -599,7 +605,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
ev_completed("resp-2"),
]),
];
mount_sse_sequence(&server, responses).await;
let response_mock = mount_sse_sequence(&server, responses).await;
let cwd_path = cwd.abs();
let (sandbox_policy, permission_profile) =
@@ -631,6 +637,24 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let request = response_mock
.requests()
.into_iter()
.next()
.expect("remote model should receive an inference request");
let body = request.body_json();
let tools = body["tools"]
.as_array()
.expect("remote model tools should be present");
for tool_name in ["exec_command", "write_stdin"] {
assert!(
tools
.iter()
.any(|tool| tool["name"].as_str() == Some(tool_name)),
"legacy remote model metadata should expose {tool_name}: {tools:?}"
);
}
Ok(())
}
@@ -749,7 +773,7 @@ async fn remote_models_apply_legacy_instructions() -> Result<()> {
effort: ReasoningEffort::Medium,
description: ReasoningEffort::Medium.to_string(),
}],
shell_type: ConfigShellToolType::ShellCommand,
shell_type: ConfigShellToolType::UnifiedExec,
visibility: ModelVisibility::List,
supported_in_api: true,
input_modalities: default_input_modalities(),
@@ -1331,7 +1355,7 @@ fn test_remote_model_with_policy(
effort: ReasoningEffort::Medium,
description: ReasoningEffort::Medium.to_string(),
}],
shell_type: ConfigShellToolType::ShellCommand,
shell_type: ConfigShellToolType::UnifiedExec,
visibility,
supported_in_api: true,
input_modalities: default_input_modalities(),
+24 -178
View File
@@ -42,7 +42,6 @@ use core_test_support::responses::sse;
use core_test_support::responses::sse_response;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::skip_if_remote;
use core_test_support::skip_if_sandbox;
use core_test_support::skip_if_target_windows;
use core_test_support::skip_if_wine_exec;
@@ -109,19 +108,19 @@ fn parse_result(item: &Value) -> CommandResult {
}
}
fn shell_event_with_request_permissions<S: serde::Serialize>(
fn command_event_with_request_permissions<S: serde::Serialize>(
call_id: &str,
command: &str,
additional_permissions: &S,
) -> Result<Value> {
let args = json!({
"command": command,
"timeout_ms": 1_000_u64,
"cmd": command,
"yield_time_ms": 10_000_u64,
"sandbox_permissions": SandboxPermissions::WithAdditionalPermissions,
"additional_permissions": additional_permissions,
});
let args_str = serde_json::to_string(&args)?;
Ok(ev_function_call(call_id, "shell_command", &args_str))
Ok(ev_function_call(call_id, "exec_command", &args_str))
}
fn request_permissions_tool_event(
@@ -137,13 +136,13 @@ fn request_permissions_tool_event(
Ok(ev_function_call(call_id, "request_permissions", &args_str))
}
fn shell_command_event(call_id: &str, command: &str) -> Result<Value> {
fn command_event(call_id: &str, command: &str) -> Result<Value> {
let args = json!({
"command": command,
"timeout_ms": 1_000_u64,
"cmd": command,
"yield_time_ms": 10_000_u64,
});
let args_str = serde_json::to_string(&args)?;
Ok(ev_function_call(call_id, "shell_command", &args_str))
Ok(ev_function_call(call_id, "exec_command", &args_str))
}
fn exec_command_event(call_id: &str, command: &str) -> Result<Value> {
@@ -363,7 +362,7 @@ async fn with_additional_permissions_requires_approval_under_on_request() -> Res
)),
..Default::default()
};
let event = shell_event_with_request_permissions(call_id, command, &requested_permissions)?;
let event = command_event_with_request_permissions(call_id, command, &requested_permissions)?;
let _ = mount_sse_once(
&server,
@@ -760,18 +759,8 @@ async fn interrupted_request_permissions_auto_review_aborts_guardian_review() ->
Ok(())
}
#[derive(Clone, Copy, Debug)]
enum AdditionalPermissionsCommandTool {
ShellCommand,
ExecCommand,
}
#[test_case(AdditionalPermissionsCommandTool::ShellCommand ; "shell_command")]
#[test_case(AdditionalPermissionsCommandTool::ExecCommand ; "exec_command")]
#[tokio::test(flavor = "current_thread")]
async fn relative_additional_permissions_resolve_against_tool_workdir(
command_tool: AdditionalPermissionsCommandTool,
) -> Result<()> {
async fn relative_additional_permissions_resolve_against_tool_workdir() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
@@ -818,26 +807,13 @@ async fn relative_additional_permissions_resolve_against_tool_workdir(
)),
..Default::default()
};
let (tool_name, arguments) = match command_tool {
AdditionalPermissionsCommandTool::ShellCommand => (
"shell_command",
json!({
"command": command,
"workdir": workdir,
"sandbox_permissions": SandboxPermissions::WithAdditionalPermissions,
"additional_permissions": additional_permissions,
}),
),
AdditionalPermissionsCommandTool::ExecCommand => (
"exec_command",
json!({
"cmd": command,
"workdir": workdir,
"sandbox_permissions": SandboxPermissions::WithAdditionalPermissions,
"additional_permissions": additional_permissions,
}),
),
};
let tool_name = "exec_command";
let arguments = json!({
"cmd": command,
"workdir": workdir,
"sandbox_permissions": SandboxPermissions::WithAdditionalPermissions,
"additional_permissions": additional_permissions,
});
let event = ev_function_call(call_id, tool_name, &serde_json::to_string(&arguments)?);
let _ = mount_sse_once(
@@ -935,7 +911,7 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_cwd
)),
..Default::default()
};
let event = shell_event_with_request_permissions(call_id, &command, &requested_permissions)?;
let event = command_event_with_request_permissions(call_id, &command, &requested_permissions)?;
let _ = mount_sse_once(
&server,
@@ -1039,7 +1015,7 @@ async fn read_only_with_additional_permissions_does_not_widen_to_unrequested_tmp
)),
..Default::default()
};
let event = shell_event_with_request_permissions(call_id, &command, &requested_permissions)?;
let event = command_event_with_request_permissions(call_id, &command, &requested_permissions)?;
let _ = mount_sse_once(
&server,
@@ -1150,7 +1126,7 @@ async fn workspace_write_with_additional_permissions_can_write_outside_cwd() ->
)),
..RequestPermissionProfile::default()
};
let event = shell_event_with_request_permissions(call_id, &command, &requested_permissions)?;
let event = command_event_with_request_permissions(call_id, &command, &requested_permissions)?;
let _ = mount_sse_once(
&server,
@@ -1255,7 +1231,7 @@ async fn with_additional_permissions_denied_approval_blocks_execution() -> Resul
)),
..Default::default()
};
let event = shell_event_with_request_permissions(call_id, &command, &requested_permissions)?;
let event = command_event_with_request_permissions(call_id, &command, &requested_permissions)?;
let _ = mount_sse_once(
&server,
@@ -1560,121 +1536,7 @@ async fn request_permissions_preapprove_explicit_exec_permissions_outside_on_req
}
#[tokio::test(flavor = "current_thread")]
async fn request_permissions_grants_apply_to_later_shell_command_calls() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
let server = start_mock_server().await;
let approval_policy = AskForApproval::OnRequest;
let permission_profile = workspace_write_excluding_tmp();
let permission_profile_for_config = workspace_write_excluding_tmp();
let mut builder = test_codex().with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
config
.permissions
.set_permission_profile(permission_profile_for_config)
.expect("set permission profile");
config
.features
.enable(Feature::ExecPermissionApprovals)
.expect("test config should allow feature update");
config
.features
.enable(Feature::RequestPermissionsTool)
.expect("test config should allow feature update");
});
let test = builder.build(&server).await?;
let outside_dir = tempfile::tempdir()?;
let outside_write = outside_dir.path().join("sticky-shell-write.txt");
let command = format!(
"printf {:?} > {:?} && cat {:?}",
"sticky-shell-grant-ok", outside_write, outside_write
);
let requested_permissions = requested_directory_write_permissions(outside_dir.path());
let normalized_requested_permissions =
normalized_directory_write_permissions(outside_dir.path())?;
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-sticky-shell-1"),
request_permissions_tool_event(
"permissions-call",
"Allow writing outside the workspace",
&requested_permissions,
)?,
ev_completed("resp-sticky-shell-1"),
]),
sse(vec![
ev_response_created("resp-sticky-shell-2"),
shell_command_event("shell-call", &command)?,
ev_completed("resp-sticky-shell-2"),
]),
sse(vec![
ev_response_created("resp-sticky-shell-3"),
ev_assistant_message("msg-sticky-shell-1", "done"),
ev_completed("resp-sticky-shell-3"),
]),
],
)
.await;
submit_turn(
&test,
"write outside the workspace",
approval_policy,
permission_profile,
)
.await?;
let granted_permissions = expect_request_permissions_event(&test, "permissions-call").await;
assert_eq!(
granted_permissions,
normalized_requested_permissions.clone()
);
test.codex
.submit(Op::RequestPermissionsResponse {
id: "permissions-call".to_string(),
response: RequestPermissionsResponse {
permissions: normalized_requested_permissions.clone(),
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
},
})
.await?;
if let Some(approval) = wait_for_exec_approval_or_completion(&test).await {
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::Approved,
})
.await?;
wait_for_completion(&test).await;
}
let shell_output = responses
.function_call_output_text("shell-call")
.map(|output| json!({ "output": output }))
.expect("expected shell-call output");
let result = parse_result(&shell_output);
assert!(
result.exit_code.is_none_or(|exit_code| exit_code == 0),
"expected success output, got exit_code={:?}, stdout={:?}",
result.exit_code,
result.stdout
);
assert_eq!(result.stdout.trim(), "sticky-shell-grant-ok");
assert_eq!(fs::read_to_string(&outside_write)?, "sticky-shell-grant-ok");
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn request_permissions_grants_apply_to_later_shell_command_calls_without_inline_permission_feature()
async fn request_permissions_grants_apply_to_later_exec_command_calls_without_inline_permission_feature()
-> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
@@ -1722,7 +1584,7 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls_without_i
]),
sse(vec![
ev_response_created("resp-sticky-shell-independent-2"),
shell_command_event("shell-call", &command)?,
command_event("shell-call", &command)?,
ev_completed("resp-sticky-shell-independent-2"),
]),
sse(vec![
@@ -2224,7 +2086,6 @@ const WRITE_CALL_ID: &str = "denied-child-permissions-write";
#[derive(Clone, Copy, Debug)]
enum WriteTool {
ExecCommand,
ShellCommand,
ApplyPatch,
}
@@ -2237,8 +2098,6 @@ enum ApprovalMode {
#[test_case(WriteTool::ExecCommand, PermissionGrantScope::Turn, ApprovalMode::Prompt; "exec_command_turn")]
#[test_case(WriteTool::ExecCommand, PermissionGrantScope::Session, ApprovalMode::Prompt; "exec_command_session")]
#[test_case(WriteTool::ShellCommand, PermissionGrantScope::Turn, ApprovalMode::Prompt; "shell_command_turn")]
#[test_case(WriteTool::ShellCommand, PermissionGrantScope::Session, ApprovalMode::Prompt; "shell_command_session")]
#[test_case(WriteTool::ApplyPatch, PermissionGrantScope::Turn, ApprovalMode::Prompt; "apply_patch_turn")]
#[test_case(WriteTool::ApplyPatch, PermissionGrantScope::Session, ApprovalMode::Prompt; "apply_patch_session")]
#[test_case(WriteTool::ExecCommand, PermissionGrantScope::Session, ApprovalMode::Never; "exec_command_session_never")]
@@ -2255,13 +2114,6 @@ async fn denied_child_permissions_require_fresh_approval(
Ok(()),
"this regression exercises POSIX split-policy enforcement; a disabled Windows sandbox can independently prompt for the command"
);
if matches!(tool, WriteTool::ShellCommand) {
skip_if_remote!(
Ok(()),
"the legacy shell_command tool is only registered for a single local environment"
);
}
let harness =
TestCodexHarness::with_auto_env_builder(test_codex().with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
@@ -2322,9 +2174,6 @@ async fn denied_child_permissions_require_fresh_approval(
&command,
&fresh_permissions,
)?,
WriteTool::ShellCommand => {
shell_event_with_request_permissions(WRITE_CALL_ID, &command, &fresh_permissions)?
}
WriteTool::ApplyPatch => ev_apply_patch_custom_tool_call(
WRITE_CALL_ID,
&format!(
@@ -2425,10 +2274,7 @@ async fn denied_child_permissions_require_fresh_approval(
}
let (decision, reason) = match (tool, event) {
(
WriteTool::ExecCommand | WriteTool::ShellCommand,
EventMsg::ExecApprovalRequest(approval),
) => {
(WriteTool::ExecCommand, EventMsg::ExecApprovalRequest(approval)) => {
assert_eq!(approval.call_id, WRITE_CALL_ID);
let expected_permissions = PermissionProfile {
file_system: Some(FileSystemPermissions {
@@ -207,15 +207,15 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul
let server = start_mock_server().await;
let tool_args = serde_json::json!({
"command": "echo hello",
"timeout_ms": 1_000
"cmd": "echo hello",
"yield_time_ms": 1_000
});
let first_response = sse_response(sse(vec![
ev_response_created("resp-1"),
ev_function_call(
"call-1",
"shell_command",
"exec_command",
&serde_json::to_string(&tool_args)?,
),
core_test_support::responses::ev_completed("resp-1"),
@@ -364,15 +364,15 @@ async fn model_verification_only_emits_once_per_turn() -> Result<()> {
let server = start_mock_server().await;
let tool_args = serde_json::json!({
"command": "echo hello",
"timeout_ms": 1_000
"cmd": "echo hello",
"yield_time_ms": 1_000
});
let first_response = sse_response(sse(vec![
ev_response_created("resp-1"),
ev_function_call(
"call-1",
"shell_command",
"exec_command",
&serde_json::to_string(&tool_args)?,
),
ev_model_verification_metadata("resp-1", vec![TRUSTED_ACCESS_FOR_CYBER_VERIFICATION]),
-391
View File
@@ -1,391 +0,0 @@
use std::time::Duration;
use anyhow::Result;
use codex_protocol::models::PermissionProfile;
use codex_protocol::shell_environment::CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR;
use core_test_support::TestTargetOs;
use core_test_support::assert_regex_match;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::skip_if_host_windows;
use core_test_support::skip_if_no_network;
use core_test_support::skip_if_wine_exec;
use core_test_support::test_codex::TestCodexBuilder;
use core_test_support::test_codex::TestCodexHarness;
use core_test_support::test_codex::test_codex;
use core_test_support::test_target_os;
use pretty_assertions::assert_eq;
use serde_json::json;
use test_case::test_case;
#[cfg(windows)]
const DEFAULT_SHELL_TIMEOUT_MS: i64 = 7_000;
#[cfg(not(windows))]
const DEFAULT_SHELL_TIMEOUT_MS: i64 = 2_000;
#[cfg(windows)]
const MEDIUM_TIMEOUT: Duration = Duration::from_secs(10);
#[cfg(not(windows))]
const MEDIUM_TIMEOUT: Duration = Duration::from_secs(5);
fn shell_responses_with_timeout(
call_id: &str,
command: &str,
login: Option<bool>,
timeout_ms: i64,
) -> Vec<String> {
let args = json!({
"command": command,
"timeout_ms": timeout_ms,
"login": login,
});
let arguments = serde_json::to_string(&args).expect("serialize shell command arguments");
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell_command", &arguments),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
]
}
fn shell_responses(call_id: &str, command: &str, login: Option<bool>) -> Vec<String> {
shell_responses_with_timeout(call_id, command, login, DEFAULT_SHELL_TIMEOUT_MS)
}
async fn shell_command_harness_with(
configure: impl FnOnce(TestCodexBuilder) -> TestCodexBuilder,
) -> Result<TestCodexHarness> {
let builder = configure(test_codex());
TestCodexHarness::with_builder(builder).await
}
async fn mount_shell_responses(
harness: &TestCodexHarness,
call_id: &str,
command: &str,
login: Option<bool>,
) {
mount_sse_sequence(harness.server(), shell_responses(call_id, command, login)).await;
}
async fn mount_shell_responses_with_timeout(
harness: &TestCodexHarness,
call_id: &str,
command: &str,
login: Option<bool>,
timeout: Duration,
) {
mount_sse_sequence(
harness.server(),
shell_responses_with_timeout(call_id, command, login, timeout.as_millis() as i64),
)
.await;
}
fn assert_shell_command_output(output: &str, expected: &str) -> Result<()> {
let normalized_output = output
.replace("\r\n", "\n")
.replace('\r', "\n")
.trim_end_matches('\n')
.to_string();
let expected_pattern = format!(
r"(?s)^Exit code: 0\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\n{expected}\n?$"
);
assert_regex_match(&expected_pattern, &normalized_output);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_command_works() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.4")).await?;
let call_id = "shell-command-call";
mount_shell_responses(
&harness,
call_id,
"echo 'hello, world'",
/*login*/ None,
)
.await;
harness.submit("run the echo command").await?;
let output = harness.function_call_stdout(call_id).await;
assert_shell_command_output(&output, "hello, world")?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_command_does_not_expose_configured_noise_auth_token() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_wine_exec!(Ok(()), "shell_command is unavailable for Wine executors");
let builder = test_codex().with_model("gpt-5.4").with_config(|config| {
config.permissions.shell_environment_policy.r#set.insert(
CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR.to_string(),
"configured-noise-token".to_string(),
);
config.permissions.shell_environment_policy.r#set.insert(
CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR.to_ascii_lowercase(),
"case-variant-noise-token".to_string(),
);
});
let harness = TestCodexHarness::with_auto_env_builder(builder).await?;
let command = match test_target_os() {
TestTargetOs::Linux | TestTargetOs::MacOs => {
"if [ -n \"${CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN:-}\" ] || [ -n \"${codex_exec_server_noise_auth_token:-}\" ]; then echo leaked; else echo unset; fi"
}
TestTargetOs::Windows => {
"if ($env:CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN) { Write-Output leaked } else { Write-Output unset }"
}
};
let call_id = "shell-command-noise-auth-token";
mount_shell_responses(&harness, call_id, command, /*login*/ None).await;
harness
.submit("check the remote execution auth token")
.await?;
assert_shell_command_output(&harness.function_call_stdout(call_id).await, "unset")?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_command_rejects_justification_without_sandbox_permissions() -> Result<()> {
skip_if_no_network!(Ok(()));
let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.4")).await?;
let call_id = "shell-command-missing-sandbox-permissions";
let args = json!({
"command": "echo should not run",
"justification": "Allow this command",
});
mount_sse_sequence(
harness.server(),
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
],
)
.await;
harness
.submit_with_permission_profile(
"run the command with escalation",
PermissionProfile::Disabled,
)
.await?;
assert_eq!(
harness.function_call_stdout(call_id).await,
"`justification` requires an explicit `sandbox_permissions`; use `sandbox_permissions: \"require_escalated\"` for unsandboxed execution, or omit `justification`."
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn output_with_login() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.4")).await?;
let call_id = "shell-command-call-login-true";
mount_shell_responses(&harness, call_id, "echo 'hello, world'", Some(true)).await;
harness.submit("run the echo command with login").await?;
let output = harness.function_call_stdout(call_id).await;
assert_shell_command_output(&output, "hello, world")?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn output_without_login() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.4")).await?;
let call_id = "shell-command-call-login-false";
mount_shell_responses(&harness, call_id, "echo 'hello, world'", Some(false)).await;
harness.submit("run the echo command without login").await?;
let output = harness.function_call_stdout(call_id).await;
assert_shell_command_output(&output, "hello, world")?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn multi_line_output_with_login() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.4")).await?;
let call_id = "shell-command-call-first-extra-login";
mount_shell_responses(
&harness,
call_id,
"echo 'first line\nsecond line'",
Some(true),
)
.await;
harness.submit("run the command with login").await?;
let output = harness.function_call_stdout(call_id).await;
assert_shell_command_output(&output, "first line\nsecond line")?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pipe_output_with_login() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
skip_if_host_windows!(Ok(()));
let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.4")).await?;
let call_id = "shell-command-call-second-extra-no-login";
mount_shell_responses(
&harness,
call_id,
"echo 'hello, world' | cat",
/*login*/ None,
)
.await;
harness.submit("run the command without login").await?;
let output = harness.function_call_stdout(call_id).await;
assert_shell_command_output(&output, "hello, world")?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pipe_output_without_login() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
skip_if_host_windows!(Ok(()));
let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.4")).await?;
let call_id = "shell-command-call-third-extra-login-false";
mount_shell_responses(&harness, call_id, "echo 'hello, world' | cat", Some(false)).await;
harness.submit("run the command without login").await?;
let output = harness.function_call_stdout(call_id).await;
assert_shell_command_output(&output, "hello, world")?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_command_times_out_with_timeout_ms() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.4")).await?;
let call_id = "shell-command-timeout";
let command = if cfg!(windows) {
"Start-Sleep -Seconds 5"
} else {
"sleep 5"
};
mount_shell_responses_with_timeout(
&harness,
call_id,
command,
/*login*/ None,
Duration::from_millis(200),
)
.await;
harness
.submit("run a long command with a short timeout")
.await?;
let output = harness.function_call_stdout(call_id).await;
let normalized_output = output
.replace("\r\n", "\n")
.replace('\r', "\n")
.trim_end_matches('\n')
.to_string();
let expected_pattern = r"(?s)^Exit code: 124\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\ncommand timed out after [0-9]+ milliseconds\n?$";
assert_regex_match(expected_pattern, &normalized_output);
Ok(())
}
/// This test verifies that a shell, particularly PowerShell, can correctly
/// handle unicode output when the UTF-8 BOM is used. See
/// https://github.com/openai/codex/pull/7902 for more context.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(true ; "with_login")]
#[test_case(false ; "without_login")]
async fn unicode_output(login: bool) -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.2")).await?;
let call_id = "unicode_output";
let command = if cfg!(windows) {
// We use a child process on Windows instead of a PowerShell command
// like `Write-Output` to ensure that the Powershell config is set
// correctly.
"cmd.exe /c echo naïve_café"
} else {
"echo \"naïve_café\""
};
mount_shell_responses_with_timeout(&harness, call_id, command, Some(login), MEDIUM_TIMEOUT)
.await;
harness.submit("run the command without login").await?;
let output = harness.function_call_stdout(call_id).await;
assert_shell_command_output(&output, "naïve_café")?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(true ; "with_login")]
#[test_case(false ; "without_login")]
async fn unicode_output_with_newlines(login: bool) -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.2")).await?;
let call_id = "unicode_output";
mount_shell_responses_with_timeout(
&harness,
call_id,
"echo 'line1\nnaïve café\nline3'",
Some(login),
MEDIUM_TIMEOUT,
)
.await;
harness.submit("run the command without login").await?;
let output = harness.function_call_stdout(call_id).await;
assert_shell_command_output(&output, "line1\\nnaïve café\\nline3")?;
Ok(())
}
@@ -1,456 +0,0 @@
#![cfg(not(target_os = "windows"))]
use anyhow::Result;
use codex_protocol::models::PermissionProfile;
use core_test_support::assert_regex_match;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::skip_if_target_windows;
use core_test_support::test_codex::test_codex;
use pretty_assertions::assert_eq;
use regex_lite::Regex;
use serde_json::Value;
use serde_json::json;
use std::fs;
use crate::suite::apply_patch_cli::apply_patch_harness;
use crate::suite::apply_patch_cli::mount_apply_patch;
const FIXTURE_JSON: &str = r#"{
"description": "This is an example JSON file.",
"foo": "bar",
"isTest": true,
"testNumber": 123,
"testArray": [1, 2, 3],
"testObject": {
"foo": "bar"
}
}
"#;
fn shell_responses(call_id: &str, command: Vec<&str>) -> Result<Vec<String>> {
let command = shlex::try_join(command)?;
let parameters = json!({
"command": command,
"timeout_ms": 2_000,
});
Ok(vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(
call_id,
"shell_command",
&serde_json::to_string(&parameters)?,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
])
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_output_preserves_fixture_json_as_freeform() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex().with_model("test-gpt-5-codex");
let test = builder.build(&server).await?;
let fixture_path = test.cwd.path().join("fixture.json");
fs::write(&fixture_path, FIXTURE_JSON)?;
let fixture_path_str = fixture_path.to_string_lossy().to_string();
let call_id = "shell-freeform-fixture";
let responses = shell_responses(
call_id,
vec!["/usr/bin/sed", "-n", "p", fixture_path_str.as_str()],
)?;
let mock = mount_sse_sequence(&server, responses).await;
test.submit_turn_with_permission_profile(
"read the fixture JSON with shell output",
PermissionProfile::Disabled,
)
.await?;
let req = mock.last_request().expect("shell output request recorded");
let output_item = req.function_call_output(call_id);
let output = output_item
.get("output")
.and_then(Value::as_str)
.expect("shell output string");
assert!(
serde_json::from_str::<Value>(output).is_err(),
"expected shell output to be plain text"
);
let (header, body) = output
.split_once("Output:\n")
.expect("shell output contains an Output section");
assert_regex_match(
r"(?s)^Exit code: 0\nWall time: [0-9]+(?:\.[0-9]+)? seconds$",
header.trim_end(),
);
assert_eq!(
body, FIXTURE_JSON,
"expected Output section to include the fixture contents"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_output_records_duration() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex().with_model("test-gpt-5-codex");
let test = builder.build(&server).await?;
let call_id = "shell-freeform";
let responses = shell_responses(call_id, vec!["/bin/sh", "-c", "sleep 0.2"])?;
let mock = mount_sse_sequence(&server, responses).await;
test.submit_turn_with_permission_profile("run the shell command", PermissionProfile::Disabled)
.await?;
let req = mock.last_request().expect("shell output request recorded");
let output_item = req.function_call_output(call_id);
let output = output_item
.get("output")
.and_then(Value::as_str)
.expect("shell output string");
let expected_pattern = r#"(?s)^Exit code: 0
Wall time: [0-9]+(?:\.[0-9]+)? seconds
Output:
$"#;
assert_regex_match(expected_pattern, output);
let wall_time_regex = Regex::new(r"(?m)^Wall (?:time|Clock): ([0-9]+(?:\.[0-9]+)?) seconds$")
.expect("compile wall time regex");
let wall_time_seconds = wall_time_regex
.captures(output)
.and_then(|caps| caps.get(1))
.and_then(|value| value.as_str().parse::<f32>().ok())
.expect("expected shell output to contain wall time seconds");
assert!(
wall_time_seconds > 0.1,
"expected wall time to be greater than zero seconds, got {wall_time_seconds}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_custom_tool_call_creates_file() -> Result<()> {
skip_if_no_network!(Ok(()));
let harness = apply_patch_harness().await?;
let call_id = "apply-patch-add-file";
let file_name = "custom_tool_apply_patch.txt";
let patch = format!(
"*** Begin Patch\n*** Add File: {file_name}\n+custom tool content\n*** End Patch\n"
);
mount_apply_patch(&harness, call_id, &patch, "apply_patch done").await;
harness
.test()
.submit_turn_with_permission_profile(
"apply the patch via custom tool to create a file",
PermissionProfile::Disabled,
)
.await?;
let output = harness.apply_patch_output(call_id).await;
let expected_pattern = format!(
r"(?s)^Exit code: 0
Wall time: [0-9]+(?:\.[0-9]+)? seconds
Output:
Success. Updated the following files:
A {file_name}
?$"
);
assert_regex_match(&expected_pattern, output.as_str());
let created_contents = harness.read_file_text(file_name).await?;
assert_eq!(
created_contents, "custom tool content\n",
"expected file contents for {file_name}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_custom_tool_call_updates_existing_file() -> Result<()> {
skip_if_no_network!(Ok(()));
let harness = apply_patch_harness().await?;
let call_id = "apply-patch-update-file";
let file_name = "custom_tool_apply_patch_existing.txt";
harness.write_file(file_name, "before\n").await?;
let patch = format!(
"*** Begin Patch\n*** Update File: {file_name}\n@@\n-before\n+after\n*** End Patch\n"
);
mount_apply_patch(&harness, call_id, &patch, "apply_patch update done").await;
harness
.test()
.submit_turn_with_permission_profile(
"apply the patch via custom tool to update a file",
PermissionProfile::Disabled,
)
.await?;
let output = harness.apply_patch_output(call_id).await;
let expected_pattern = format!(
r"(?s)^Exit code: 0
Wall time: [0-9]+(?:\.[0-9]+)? seconds
Output:
Success. Updated the following files:
M {file_name}
?$"
);
assert_regex_match(&expected_pattern, output.as_str());
let updated_contents = harness.read_file_text(file_name).await?;
assert_eq!(updated_contents, "after\n", "expected updated file content");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_custom_tool_call_reports_failure_output() -> Result<()> {
// TODO(anp): Remove after apply-patch assertions use target-native paths.
skip_if_target_windows!(Ok(()), "asserts POSIX apply_patch failure text");
skip_if_no_network!(Ok(()));
let harness = apply_patch_harness().await?;
let call_id = "apply-patch-failure";
let missing_file = "missing_custom_tool_apply_patch.txt";
let patch = format!(
"*** Begin Patch\n*** Update File: {missing_file}\n@@\n-before\n+after\n*** End Patch\n"
);
mount_apply_patch(&harness, call_id, &patch, "apply_patch failure done").await;
harness
.test()
.submit_turn_with_permission_profile(
"attempt a failing apply_patch via custom tool",
PermissionProfile::Disabled,
)
.await?;
let output = harness.apply_patch_output(call_id).await;
let expected_output = format!(
"apply_patch verification failed: Failed to read file to update {}/{missing_file}: No such file or directory (os error 2)",
harness.cwd().to_string_lossy()
);
assert_eq!(output, expected_output.as_str());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_output_is_freeform_for_nonzero_exit() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex().with_model("gpt-5.4");
let test = builder.build(&server).await?;
let call_id = "shell-nonzero-exit";
let responses = shell_responses(call_id, vec!["/bin/sh", "-c", "exit 42"])?;
let mock = mount_sse_sequence(&server, responses).await;
test.submit_turn_with_permission_profile(
"run the failing shell command",
PermissionProfile::Disabled,
)
.await?;
let req = mock.last_request().expect("shell output request recorded");
let output_item = req.function_call_output(call_id);
let output = output_item
.get("output")
.and_then(Value::as_str)
.expect("shell output string");
let expected_pattern = r"(?s)^Exit code: 42
Wall time: [0-9]+(?:\.[0-9]+)? seconds
Output:
?$";
assert_regex_match(expected_pattern, output);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_command_output_is_freeform() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex();
let test = builder.build(&server).await?;
let call_id = "shell-command";
let args = json!({
"command": "echo shell command",
"login": false,
"timeout_ms": 1_000,
});
let responses = vec![
sse(vec![
json!({"type": "response.created", "response": {"id": "resp-1"}}),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", "shell_command done"),
ev_completed("resp-2"),
]),
];
let mock = mount_sse_sequence(&server, responses).await;
test.submit_turn_with_permission_profile(
"run the shell_command script in the user's shell",
PermissionProfile::Disabled,
)
.await?;
let req = mock
.last_request()
.expect("shell_command output request recorded");
let output_item = req.function_call_output(call_id);
let output = output_item
.get("output")
.and_then(Value::as_str)
.expect("shell_command output string");
let expected_pattern = r"(?s)^Exit code: 0
Wall time: [0-9]+(?:\.[0-9]+)? seconds
Output:
shell command
?$";
assert_regex_match(expected_pattern, output);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_command_output_is_not_truncated_under_10k_bytes() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex().with_model("gpt-5.4");
let test = builder.build(&server).await?;
let call_id = "shell-command";
let args = json!({
"command": "perl -e 'print \"1\" x 10000'",
"login": false,
"timeout_ms": 1000,
});
let responses = vec![
sse(vec![
json!({"type": "response.created", "response": {"id": "resp-1"}}),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", "shell_command done"),
ev_completed("resp-2"),
]),
];
let mock = mount_sse_sequence(&server, responses).await;
test.submit_turn_with_permission_profile(
"run the shell_command script in the user's shell",
PermissionProfile::Disabled,
)
.await?;
let req = mock
.last_request()
.expect("shell_command output request recorded");
let output_item = req.function_call_output(call_id);
let output = output_item
.get("output")
.and_then(Value::as_str)
.expect("shell_command output string");
let expected_pattern = r"(?s)^Exit code: 0
Wall time: [0-9]+(?:\.[0-9]+)? seconds
Output:
1{10000}$";
assert_regex_match(expected_pattern, output);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_command_output_is_not_truncated_over_10k_bytes() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex().with_model("gpt-5.2");
let test = builder.build(&server).await?;
let call_id = "shell-command";
let args = json!({
"command": "perl -e 'print \"1\" x 10001'",
"login": false,
"timeout_ms": 1000,
});
let responses = vec![
sse(vec![
json!({"type": "response.created", "response": {"id": "resp-1"}}),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", "shell_command done"),
ev_completed("resp-2"),
]),
];
let mock = mount_sse_sequence(&server, responses).await;
test.submit_turn_with_permission_profile(
"run the shell_command script in the user's shell",
PermissionProfile::Disabled,
)
.await?;
let req = mock
.last_request()
.expect("shell_command output request recorded");
let output_item = req.function_call_output(call_id);
let output = output_item
.get("output")
.and_then(Value::as_str)
.expect("shell_command output string");
let expected_pattern = r"(?s)^Exit code: 0
Wall time: [0-9]+(?:\.[0-9]+)? seconds
Output:
1*…1 chars truncated…1*$";
assert_regex_match(expected_pattern, output);
Ok(())
}
+13 -177
View File
@@ -215,101 +215,6 @@ async fn run_snapshot_command_with_options(
})
}
async fn run_shell_command_snapshot(command: &str) -> Result<SnapshotRun> {
run_shell_command_snapshot_with_options(command, SnapshotRunOptions::default()).await
}
async fn run_shell_command_snapshot_with_options(
command: &str,
options: SnapshotRunOptions,
) -> Result<SnapshotRun> {
let SnapshotRunOptions {
shell_environment_set,
} = options;
let builder = test_codex().with_config(move |config| {
config
.features
.enable(Feature::ShellSnapshot)
.expect("test config should allow feature update");
config.permissions.shell_environment_policy.r#set = shell_environment_set;
});
let harness = TestCodexHarness::with_builder(builder).await?;
let args = json!({
"command": command,
"timeout_ms": 1000,
});
let call_id = "shell-snapshot-command";
let responses = vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
];
mount_sse_sequence(harness.server(), responses).await;
let test = harness.test();
let codex = test.codex.clone();
let codex_home = test.home.path().to_path_buf();
let session_model = test.session_configured.model.clone();
let cwd = test.config.cwd.clone();
let (sandbox_policy, permission_profile) =
turn_permission_fields(PermissionProfile::Disabled, cwd.as_path());
codex
.start_or_steer_turn(
TurnInputRequest::user_input(vec![UserInput::Text {
text: "run shell_command with shell snapshot".into(),
text_elements: Vec::new(),
}])
.with_thread_settings(ThreadSettingsOverrides {
environments: Some(local_selections(cwd)),
approval_policy: Some(AskForApproval::Never),
sandbox_policy: Some(sandbox_policy),
permission_profile,
collaboration_mode: Some(CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
model: session_model,
reasoning_effort: None,
developer_instructions: None,
},
}),
..Default::default()
}),
)
.await?;
let begin = wait_for_event_match(&codex, |ev| match ev {
EventMsg::ExecCommandBegin(ev) if ev.call_id == call_id => Some(ev.clone()),
_ => None,
})
.await;
let snapshot_path = wait_for_snapshot(&codex_home).await?;
let snapshot_content = fs::read_to_string(&snapshot_path).await?;
let end = wait_for_event_match(&codex, |ev| match ev {
EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => Some(ev.clone()),
_ => None,
})
.await;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
Ok(SnapshotRun {
begin,
end,
snapshot_path,
snapshot_content,
codex_home,
})
}
async fn run_tool_turn_on_harness(
harness: &TestCodexHarness,
prompt: &str,
@@ -413,76 +318,7 @@ async fn linux_unified_exec_uses_shell_snapshot() -> Result<()> {
#[cfg_attr(target_os = "windows", ignore)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn linux_shell_command_uses_shell_snapshot() -> Result<()> {
let command = "echo shell-command-snapshot-linux";
let run = run_shell_command_snapshot(command).await?;
assert_eq!(run.begin.command.get(1).map(String::as_str), Some("-lc"));
assert_eq!(run.begin.command.get(2).map(String::as_str), Some(command));
assert_eq!(run.begin.command.len(), 3);
assert!(run.snapshot_path.starts_with(&run.codex_home));
assert_posix_snapshot_sections(&run.snapshot_content);
assert_eq!(
normalize_newlines(&run.end.stdout).trim(),
"shell-command-snapshot-linux"
);
assert_eq!(run.end.exit_code, 0);
Ok(())
}
#[cfg_attr(target_os = "windows", ignore)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_command_snapshot_preserves_shell_environment_policy_set() -> Result<()> {
let builder = test_codex().with_config(|config| {
config
.features
.enable(Feature::ShellSnapshot)
.expect("test config should allow feature update");
config.permissions.shell_environment_policy.r#set = policy_set_path_for_test();
});
let harness = TestCodexHarness::with_builder(builder).await?;
let codex_home = harness.test().home.path().to_path_buf();
run_tool_turn_on_harness(
&harness,
"warm up shell snapshot",
"shell-snapshot-policy-warmup",
"shell_command",
json!({
"command": "printf warmup",
"timeout_ms": 1_000,
}),
)
.await?;
let snapshot_path = wait_for_snapshot(&codex_home).await?;
fs::write(&snapshot_path, snapshot_override_content_for_policy_test()).await?;
let command = command_asserting_policy_after_snapshot();
let end = run_tool_turn_on_harness(
&harness,
"verify shell policy after snapshot",
"shell-snapshot-policy-assert",
"shell_command",
json!({
"command": command,
"timeout_ms": 1_000,
}),
)
.await?;
assert_eq!(
normalize_newlines(&end.stdout).trim(),
POLICY_SUCCESS_OUTPUT
);
assert_eq!(end.exit_code, 0);
assert!(snapshot_path.starts_with(codex_home));
Ok(())
}
#[cfg_attr(not(target_os = "linux"), ignore)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn linux_unified_exec_snapshot_preserves_shell_environment_policy_set() -> Result<()> {
async fn unified_exec_snapshot_preserves_shell_environment_policy_set() -> Result<()> {
let builder = test_codex().with_config(|config| {
config.use_experimental_unified_exec_tool = true;
config
@@ -536,7 +372,7 @@ async fn linux_unified_exec_snapshot_preserves_shell_environment_policy_set() ->
#[cfg_attr(target_os = "windows", ignore)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> {
async fn unified_exec_snapshot_still_intercepts_apply_patch() -> Result<()> {
let builder = test_codex().with_config(|config| {
config
.features
@@ -553,17 +389,17 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> {
let script = "apply_patch <<'EOF'\n*** Begin Patch\n*** Add File: snapshot-apply.txt\n+hello from snapshot\n*** End Patch\nEOF\n";
let args = json!({
"command": script,
"cmd": script,
// Keep this above the default because intercepted apply_patch still
// performs filesystem work that can be slow in Bazel macOS test
// environments.
"timeout_ms": 5_000,
"yield_time_ms": 5_000,
});
let call_id = "shell-snapshot-apply-patch";
let responses = vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
sse(vec![
@@ -580,7 +416,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> {
codex
.start_or_steer_turn(
TurnInputRequest::user_input(vec![UserInput::Text {
text: "apply patch via shell_command with snapshot".into(),
text: "apply patch via unified_exec with snapshot".into(),
text_elements: Vec::new(),
}])
.with_thread_settings(ThreadSettingsOverrides {
@@ -675,7 +511,7 @@ async fn shell_snapshot_deleted_after_shutdown_with_skills() -> Result<()> {
#[cfg(target_os = "macos")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn macos_shell_command_resolves_command_from_tied_path_snapshot() -> Result<()> {
async fn macos_unified_exec_resolves_command_from_tied_path_snapshot() -> Result<()> {
let builder = test_codex()
.with_user_shell(get_shell_by_model_provided_path(&PathBuf::from("/bin/zsh")))
.with_config(|config| {
@@ -702,10 +538,10 @@ async fn macos_shell_command_resolves_command_from_tied_path_snapshot() -> Resul
&harness,
"warm up the tied PATH shell snapshot",
"shell-snapshot-tied-path-warmup",
"shell_command",
"exec_command",
json!({
"command": "printf warmup",
"timeout_ms": 5_000,
"cmd": "printf warmup",
"yield_time_ms": 5_000,
}),
)
.await?;
@@ -723,10 +559,10 @@ async fn macos_shell_command_resolves_command_from_tied_path_snapshot() -> Resul
&harness,
"resolve a command from the tied PATH snapshot",
"shell-snapshot-tied-path",
"shell_command",
"exec_command",
json!({
"command": "snapshot-only-command",
"timeout_ms": 5_000,
"cmd": "snapshot-only-command",
"yield_time_ms": 5_000,
}),
)
.await?;
+7 -8
View File
@@ -35,10 +35,10 @@ fn write_skill_metadata(home: &Path, name: &str, contents: &str) -> Result<()> {
Ok(())
}
fn shell_command_arguments(command: &str) -> Result<String> {
fn exec_command_arguments(command: &str) -> Result<String> {
Ok(serde_json::to_string(&serde_json::json!({
"command": command,
"timeout_ms": 500,
"cmd": command,
"yield_time_ms": 500,
}))?)
}
@@ -192,9 +192,9 @@ async fn shell_zsh_fork_skill_scripts_ignore_declared_permissions() -> Result<()
let command = skill_script_command(&test, "sandboxed.sh")?;
let call_id = "zsh-fork-skill-script-ignores-permissions";
let arguments = shell_command_arguments(&command)?;
let arguments = exec_command_arguments(&command)?;
let mocks =
mount_function_call_agent_response(&server, call_id, &arguments, "shell_command").await;
mount_function_call_agent_response(&server, call_id, &arguments, "exec_command").await;
submit_turn_with_policies(
&test,
@@ -259,10 +259,9 @@ async fn shell_zsh_fork_still_enforces_workspace_write_sandbox() -> Result<()> {
.await?;
let command = format!("touch {outside_path}");
let arguments = shell_command_arguments(&command)?;
let arguments = exec_command_arguments(&command)?;
let mocks =
mount_function_call_agent_response(&server, tool_call_id, &arguments, "shell_command")
.await;
mount_function_call_agent_response(&server, tool_call_id, &arguments, "exec_command").await;
submit_turn_with_policies(
&test,
@@ -85,7 +85,7 @@ fn test_model_info(
description: Some(description.to_string()),
default_reasoning_level: Some(default_reasoning_level),
supported_reasoning_levels,
shell_type: ConfigShellToolType::ShellCommand,
shell_type: ConfigShellToolType::UnifiedExec,
visibility,
supported_in_api: true,
input_modalities: default_input_modalities(),
+4 -4
View File
@@ -814,8 +814,8 @@ async fn tool_call_logs_include_thread_id() -> Result<()> {
let server = start_mock_server().await;
let call_id = "call-1";
let args = json!({
"command": "echo hello",
"timeout_ms": 1_000,
"cmd": "echo hello",
"yield_time_ms": 1_000,
"login": false,
});
let args_json = serde_json::to_string(&args)?;
@@ -824,7 +824,7 @@ async fn tool_call_logs_include_thread_id() -> Result<()> {
vec![
responses::sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell_command", &args_json),
ev_function_call(call_id, "exec_command", &args_json),
ev_completed("resp-1"),
]),
responses::sse(vec![ev_completed("resp-2")]),
@@ -850,7 +850,7 @@ async fn tool_call_logs_include_thread_id() -> Result<()> {
tracing::dispatcher::with_default(&dispatch, || {
let span = tracing::info_span!("test_log_span", thread_id = %expected_thread_id);
let _entered = span.enter();
tracing::info!("ToolCall: shell_command {{\"command\":\"echo hello\"}}");
tracing::info!("ToolCall: exec_command {{\"cmd\":\"echo hello\"}}");
});
log_db_layer.flush().await;
+2 -2
View File
@@ -33,9 +33,9 @@ use core_test_support::responses::ResponsesRequest;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_completed_with_tokens;
use core_test_support::responses::ev_exec_command_call;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::ev_shell_command_call;
use core_test_support::responses::mount_compact_json_once;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::mount_sse_sequence;
@@ -1254,7 +1254,7 @@ async fn token_budget_auto_compact_fallback_uses_buffer_until_new_context() -> R
]),
sse(vec![
ev_response_created("fallback-tool-resp"),
ev_shell_command_call(fallback_call_id, "echo fallback-note > fallback-note.txt"),
ev_exec_command_call(fallback_call_id, "echo fallback-note > fallback-note.txt"),
ev_completed_with_tokens("fallback-tool-resp", /*total_tokens*/ 10_000),
]),
sse(vec![
+5 -5
View File
@@ -62,7 +62,7 @@ fn custom_call_output(req: &ResponsesRequest, call_id: &str) -> (String, Option<
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_command_tool_executes_command_and_streams_output() -> anyhow::Result<()> {
async fn exec_command_tool_executes_command_and_streams_output() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
@@ -75,15 +75,15 @@ async fn shell_command_tool_executes_command_and_streams_output() -> anyhow::Res
..
} = builder.build(&server).await?;
let call_id = "shell-command-tool-call";
let call_id = "exec-command-tool-call";
let command_args = json!({
"command": "echo tool harness",
"cmd": "echo tool harness",
"login": false,
})
.to_string();
let first_response = sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell_command", &command_args),
ev_function_call(call_id, "exec_command", &command_args),
ev_completed("resp-1"),
]);
responses::mount_sse_once(&server, first_response).await;
@@ -128,7 +128,7 @@ async fn shell_command_tool_executes_command_and_streams_output() -> anyhow::Res
let req = second_mock.single_request();
let (output_text, _) = call_output(&req, call_id);
assert_regex_match(
r"(?s)^Exit code: 0\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\ntool harness\n?$",
r"(?s)^(?:Chunk ID: [^\n]+\n)?Wall time: [0-9]+(?:\.[0-9]+)? seconds\nProcess exited with code 0\n(?:Original token count: \d+\n)?Output:\ntool harness\n?$",
&output_text,
);
+2 -2
View File
@@ -250,9 +250,9 @@ async fn tool_start_is_not_called_when_pre_tool_hook_prevents_execution() -> Res
}),
),
(
"shell_command",
"exec_command",
"^Bash$",
json!({ "command": "echo original" }),
json!({ "cmd": "echo original" }),
json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
+20 -20
View File
@@ -17,9 +17,9 @@ use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::user_input::UserInput;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_exec_command_call_with_args;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::ev_shell_command_call_with_args;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
@@ -160,18 +160,18 @@ async fn shell_tools_run_in_parallel() -> anyhow::Result<()> {
let test = builder.build(&server).await?;
let shell_args = json!({
"command": "sleep 0.25",
"cmd": "sleep 0.25",
// Avoid user-specific shell startup cost (e.g. zsh profile scripts) in timing assertions.
"login": false,
"timeout_ms": 1_000,
"yield_time_ms": 1_000,
});
let args_one = serde_json::to_string(&shell_args)?;
let args_two = serde_json::to_string(&shell_args)?;
let first_response = sse(vec![
json!({"type": "response.created", "response": {"id": "resp-1"}}),
ev_function_call("call-1", "shell_command", &args_one),
ev_function_call("call-2", "shell_command", &args_two),
ev_function_call("call-1", "exec_command", &args_one),
ev_function_call("call-2", "exec_command", &args_two),
ev_completed("resp-1"),
]);
let second_response = sse(vec![
@@ -180,7 +180,7 @@ async fn shell_tools_run_in_parallel() -> anyhow::Result<()> {
]);
mount_sse_sequence(&server, vec![first_response, second_response]).await;
let duration = run_turn_and_measure(&test, "run shell_command twice").await?;
let duration = run_turn_and_measure(&test, "run exec_command twice").await?;
assert_parallel_duration(duration);
Ok(())
@@ -198,16 +198,16 @@ async fn mixed_parallel_tools_run_in_parallel() -> anyhow::Result<()> {
})
.to_string();
let shell_args = serde_json::to_string(&json!({
"command": "sleep 0.25",
"cmd": "sleep 0.25",
// Avoid user-specific shell startup cost in timing assertions.
"login": false,
"timeout_ms": 1_000,
"yield_time_ms": 1_000,
}))?;
let first_response = sse(vec![
json!({"type": "response.created", "response": {"id": "resp-1"}}),
ev_function_call("call-1", "test_sync_tool", &sync_args),
ev_function_call("call-2", "shell_command", &shell_args),
ev_function_call("call-2", "exec_command", &shell_args),
ev_completed("resp-1"),
]);
let second_response = sse(vec![
@@ -230,17 +230,17 @@ async fn tool_results_grouped() -> anyhow::Result<()> {
let test = build_codex_with_test_tool(&server).await?;
let shell_args = serde_json::to_string(&json!({
"command": "echo 'shell output'",
"timeout_ms": 1_000,
"cmd": "echo 'shell output'",
"yield_time_ms": 1_000,
}))?;
mount_sse_once(
&server,
sse(vec![
json!({"type": "response.created", "response": {"id": "resp-1"}}),
ev_function_call("call-1", "shell_command", &shell_args),
ev_function_call("call-2", "shell_command", &shell_args),
ev_function_call("call-3", "shell_command", &shell_args),
ev_function_call("call-1", "exec_command", &shell_args),
ev_function_call("call-2", "exec_command", &shell_args),
ev_function_call("call-3", "exec_command", &shell_args),
ev_completed("resp-1"),
]),
)
@@ -316,17 +316,17 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an
// Use a non-login shell to avoid slow, user-specific shell init (e.g. zsh profiles)
// from making this timing-based test flaky.
let args = json!({
"command": command,
"cmd": command,
"login": false,
"timeout_ms": 5_000,
"yield_time_ms": 5_000,
});
let first_chunk = sse(vec![
ev_response_created(first_response_id),
ev_shell_command_call_with_args("call-1", &args),
ev_shell_command_call_with_args("call-2", &args),
ev_shell_command_call_with_args("call-3", &args),
ev_shell_command_call_with_args("call-4", &args),
ev_exec_command_call_with_args("call-1", &args),
ev_exec_command_call_with_args("call-2", &args),
ev_exec_command_call_with_args("call-3", &args),
ev_exec_command_call_with_args("call-4", &args),
]);
let second_chunk = sse(vec![ev_completed(first_response_id)]);
let follow_up = sse(vec![
+76 -247
View File
@@ -2,8 +2,6 @@
#![allow(clippy::unwrap_used)]
use std::fs;
use std::time::Duration;
use std::time::Instant;
use anyhow::Context;
use anyhow::Result;
@@ -17,6 +15,7 @@ use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec;
use codex_protocol::dynamic_tools::DynamicToolNamespaceTool;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
@@ -45,7 +44,6 @@ use core_test_support::submit_thread_settings;
use core_test_support::test_codex::local;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use regex_lite::Regex;
use serde_json::Value;
use serde_json::json;
use test_case::test_case;
@@ -548,7 +546,7 @@ async fn namespaced_custom_tool_call_preserves_namespace_through_dispatch_and_re
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_command_escalated_permissions_rejected_then_ok() -> Result<()> {
async fn exec_command_escalated_permissions_rejected_then_ok() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
@@ -569,19 +567,19 @@ async fn shell_command_escalated_permissions_rejected_then_ok() -> Result<()> {
.await?;
let command = "echo shell ok";
let call_id_blocked = "shell-command-blocked";
let call_id_success = "shell-command-success";
let call_id_blocked = "exec-command-blocked";
let call_id_success = "exec-command-success";
let first_args = json!({
"command": command,
"cmd": command,
"login": false,
"timeout_ms": 1_000,
"yield_time_ms": 1_000,
"sandbox_permissions": SandboxPermissions::RequireEscalated,
});
let second_args = json!({
"command": command,
"cmd": command,
"login": false,
"timeout_ms": 1_000,
"yield_time_ms": 10_000,
});
mount_sse_once(
@@ -590,7 +588,7 @@ async fn shell_command_escalated_permissions_rejected_then_ok() -> Result<()> {
ev_response_created("resp-1"),
ev_function_call(
call_id_blocked,
"shell_command",
"exec_command",
&serde_json::to_string(&first_args)?,
),
ev_completed("resp-1"),
@@ -603,7 +601,7 @@ async fn shell_command_escalated_permissions_rejected_then_ok() -> Result<()> {
ev_response_created("resp-2"),
ev_function_call(
call_id_success,
"shell_command",
"exec_command",
&serde_json::to_string(&second_args)?,
),
ev_completed("resp-2"),
@@ -619,12 +617,11 @@ async fn shell_command_escalated_permissions_rejected_then_ok() -> Result<()> {
)
.await;
test.submit_text_turn("run the shell_command script")
.await?;
test.submit_text_turn("run the exec_command script").await?;
let policy = AskForApproval::Never;
let expected_message = format!(
"approval policy is {policy:?}; reject command — you should not ask for escalated permissions if the approval policy is {policy:?}"
"approval policy is {policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {policy:?}"
);
let blocked_output = second_mock
@@ -643,7 +640,7 @@ async fn shell_command_escalated_permissions_rejected_then_ok() -> Result<()> {
.and_then(|(content, _)| content)
.expect("success output string");
assert_regex_match(
r"(?s)^Exit code: 0\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\nshell ok\n?$",
r"(?s)^(?:Chunk ID: [^\n]+\n)?Wall time: [0-9]+(?:\.[0-9]+)? seconds\nProcess exited with code 0\n(?:Original token count: \d+\n)?Output:\nshell ok\n?$",
&success_output,
);
@@ -651,32 +648,32 @@ async fn shell_command_escalated_permissions_rejected_then_ok() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn sandbox_denied_shell_command_returns_original_output() -> Result<()> {
async fn sandbox_denied_exec_command_returns_original_output() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex().with_model("gpt-5.4");
let fixture = builder.build(&server).await?;
let call_id = "sandbox-denied-shell-command";
let call_id = "sandbox-denied-exec-command";
let target_path = fixture.workspace_path("sandbox-denied.txt");
let sentinel = "sandbox-denied sentinel output";
let command = format!(
"printf {sentinel:?}; printf {content:?} > {path:?}",
"printf {sentinel:?} >&2; printf {content:?} > {path:?}",
sentinel = format!("{sentinel}\n"),
content = "sandbox denied",
path = &target_path
);
let args = json!({
"command": command,
"cmd": command,
"login": false,
"timeout_ms": 5_000,
"yield_time_ms": 5_000,
});
let responses = vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
sse(vec![
@@ -696,13 +693,10 @@ async fn sandbox_denied_shell_command_returns_original_output() -> Result<()> {
let output_text = mock
.function_call_output_text(call_id)
.context("shell output present")?;
let exit_code_line = output_text
let exit_code = output_text
.lines()
.next()
.context("exit code line present")?;
let exit_code = exit_code_line
.strip_prefix("Exit code: ")
.context("exit code prefix present")?
.find_map(|line| line.strip_prefix("Process exited with code "))
.context("exit code line present")?
.trim()
.parse::<i32>()
.context("exit code is integer")?;
@@ -741,7 +735,7 @@ async fn sandbox_denied_shell_command_returns_original_output() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_command_enforces_glob_deny_read_policy() -> Result<()> {
async fn exec_command_enforces_glob_deny_read_policy() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
@@ -778,22 +772,22 @@ async fn shell_command_enforces_glob_deny_read_policy() -> Result<()> {
fs::write(&denied_path, format!("{secret}\n")).context("write denied fixture")?;
fs::write(&allowed_path, format!("{allowed}\n")).context("write allowed fixture")?;
let call_id = "shell-command-glob-deny-read";
let call_id = "exec-command-glob-deny-read";
let command = format!(
"rc=0; cat {denied_path:?} || rc=$?; cat {allowed_path:?}; exit \"$rc\"",
denied_path = denied_path.to_string_lossy(),
allowed_path = allowed_path.to_string_lossy(),
);
let args = json!({
"command": command,
"cmd": command,
"login": false,
"timeout_ms": 1_000,
"yield_time_ms": 10_000,
});
let responses = vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
sse(vec![
@@ -811,13 +805,10 @@ async fn shell_command_enforces_glob_deny_read_policy() -> Result<()> {
let output_text = mock
.function_call_output_text(call_id)
.context("shell output present")?;
let exit_code_line = output_text
let exit_code = output_text
.lines()
.next()
.context("exit code line present")?;
let exit_code = exit_code_line
.strip_prefix("Exit code: ")
.context("exit code prefix present")?
.find_map(|line| line.strip_prefix("Process exited with code "))
.context("exit code line present")?
.trim()
.parse::<i32>()
.context("exit code is integer")?;
@@ -846,7 +837,15 @@ async fn shell_command_enforces_glob_deny_read_policy() -> Result<()> {
Ok(())
}
async fn collect_tools(use_unified_exec: bool) -> Result<Vec<String>> {
#[derive(Clone, Copy, Debug)]
enum CommandToolAvailability {
Default,
LegacyUnifiedExecDisabled,
ShellToolDisabled,
ModelDisabled,
}
async fn collect_tools(availability: CommandToolAvailability) -> Result<Vec<String>> {
let server = start_mock_server().await;
let responses = vec![sse(vec![
@@ -856,19 +855,26 @@ async fn collect_tools(use_unified_exec: bool) -> Result<Vec<String>> {
])];
let mock = mount_sse_sequence(&server, responses).await;
let mut builder = test_codex().with_config(move |config| {
if use_unified_exec {
config
.features
.enable(Feature::UnifiedExec)
.expect("test config should allow feature update");
} else {
let mut builder = match availability {
CommandToolAvailability::Default => test_codex(),
CommandToolAvailability::LegacyUnifiedExecDisabled => test_codex().with_config(|config| {
config
.features
.disable(Feature::UnifiedExec)
.expect("test config should allow feature update");
}),
CommandToolAvailability::ShellToolDisabled => test_codex().with_config(|config| {
config
.features
.disable(Feature::ShellTool)
.expect("test config should allow feature update");
}),
CommandToolAvailability::ModelDisabled => {
test_codex().with_model_info_override("gpt-5.4", |model| {
model.shell_type = ConfigShellToolType::Disabled;
})
}
});
};
let test = builder.build(&server).await?;
test.submit_turn_with_approval_and_permission_profile(
@@ -886,207 +892,30 @@ async fn collect_tools(use_unified_exec: bool) -> Result<Vec<String>> {
async fn unified_exec_spec_toggle_end_to_end() -> Result<()> {
skip_if_no_network!(Ok(()));
let tools_disabled = collect_tools(/*use_unified_exec*/ false).await?;
assert!(
!tools_disabled.iter().any(|name| name == "exec_command"),
"tools list should not include exec_command when disabled: {tools_disabled:?}"
);
assert!(
!tools_disabled.iter().any(|name| name == "write_stdin"),
"tools list should not include write_stdin when disabled: {tools_disabled:?}"
);
let tools_enabled = collect_tools(/*use_unified_exec*/ true).await?;
assert!(
tools_enabled.iter().any(|name| name == "exec_command"),
"tools list should include exec_command when enabled: {tools_enabled:?}"
);
assert!(
tools_enabled.iter().any(|name| name == "write_stdin"),
"tools list should include write_stdin when enabled: {tools_enabled:?}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_command_timeout_includes_timeout_prefix_and_metadata() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex().with_model("test-gpt-5-codex");
let test = builder.build(&server).await?;
let call_id = "shell-command-timeout";
let timeout_ms = 50u64;
let args = json!({
"command": "yes line | head -n 400; sleep 1",
"login": false,
"timeout_ms": timeout_ms,
});
mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
)
.await;
let second_mock = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
)
.await;
test.submit_turn_with_approval_and_permission_profile(
"run a long command",
AskForApproval::Never,
PermissionProfile::Disabled,
)
.await?;
let timeout_item = second_mock.single_request().function_call_output(call_id);
let output_str = timeout_item
.get("output")
.and_then(Value::as_str)
.expect("timeout output string");
// The exec path can report a timeout in two ways depending on timing:
// 1) Structured JSON with exit_code 124 and a timeout prefix (preferred), or
// 2) A plain error string if the child is observed as killed by a signal first.
if let Ok(output_json) = serde_json::from_str::<Value>(output_str) {
assert_eq!(
output_json["metadata"]["exit_code"].as_i64(),
Some(124),
"expected timeout exit code 124",
);
let stdout = output_json["output"].as_str().unwrap_or_default();
assert!(
stdout.contains("command timed out"),
"timeout output missing `command timed out`: {stdout}"
);
} else {
let normalized_output = output_str
.replace("\r\n", "\n")
.replace('\r', "\n")
.trim_end_matches('\n')
.to_string();
let shell_output_pattern = r"(?s)^Exit code: 124\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\ncommand timed out after [0-9]+ milliseconds\n(?:.*)?$";
if Regex::new(shell_output_pattern)
.expect("shell timeout output regex should compile")
.is_match(&normalized_output)
{
return Ok(());
for availability in [
CommandToolAvailability::ShellToolDisabled,
CommandToolAvailability::ModelDisabled,
] {
let tools = collect_tools(availability).await?;
for command_tool in ["exec_command", "write_stdin"] {
assert!(
!tools.iter().any(|name| name == command_tool),
"tools list should not include {command_tool} for {availability:?}: {tools:?}"
);
}
}
// Fallback: accept the signal classification path to deflake the test.
let signal_pattern = r"(?is)^execution error:.*signal.*$";
assert_regex_match(signal_pattern, output_str);
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_command_timeout_handles_background_grandchild_stdout() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex().with_model("gpt-5.4").with_config(|config| {
config
.permissions
.set_permission_profile(PermissionProfile::Disabled)
.expect("set permission profile");
});
let test = builder.build(&server).await?;
let call_id = "shell-command-grandchild-timeout";
let pid_path = test.cwd.path().join("grandchild_pid.txt");
let script_path = test.cwd.path().join("spawn_detached.py");
let script = format!(
r#"import subprocess
import time
from pathlib import Path
# Spawn a detached grandchild that inherits stdout/stderr so the pipe stays open.
proc = subprocess.Popen(["/bin/sh", "-c", "sleep 60"], start_new_session=True)
Path({pid_path:?}).write_text(str(proc.pid))
time.sleep(60)
"#
);
fs::write(&script_path, script)?;
let args = json!({
"command": format!("python3 {:?}", script_path.to_string_lossy()),
"login": false,
"timeout_ms": 200,
});
mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
)
.await;
let second_mock = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
)
.await;
let start = Instant::now();
let output_str = tokio::time::timeout(Duration::from_secs(10), async {
test.submit_turn_with_approval_and_permission_profile(
"run a command with a detached grandchild",
AskForApproval::Never,
PermissionProfile::Disabled,
)
.await?;
let timeout_item = second_mock.single_request().function_call_output(call_id);
timeout_item
.get("output")
.and_then(Value::as_str)
.map(str::to_string)
.context("timeout output string")
})
.await
.context("exec call should not hang waiting for grandchild pipes to close")??;
let elapsed = start.elapsed();
if let Ok(output_json) = serde_json::from_str::<Value>(&output_str) {
assert_eq!(
output_json["metadata"]["exit_code"].as_i64(),
Some(124),
"expected timeout exit code 124",
);
} else {
let timeout_pattern = r"(?is)command timed out|timeout";
assert_regex_match(timeout_pattern, &output_str);
}
assert!(
elapsed < Duration::from_secs(9),
"command should return shortly after timeout even with live grandchildren: {elapsed:?}"
);
if let Ok(pid_str) = fs::read_to_string(&pid_path)
&& let Ok(pid) = pid_str.trim().parse::<libc::pid_t>()
{
unsafe { libc::kill(pid, libc::SIGKILL) };
for availability in [
CommandToolAvailability::Default,
CommandToolAvailability::LegacyUnifiedExecDisabled,
] {
let tools = collect_tools(availability).await?;
for command_tool in ["exec_command", "write_stdin"] {
assert!(
tools.iter().any(|name| name == command_tool),
"tools list should include {command_tool} for {availability:?}: {tools:?}"
);
}
}
Ok(())
+53 -42
View File
@@ -13,6 +13,7 @@ use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::protocol::TruncationPolicy;
use codex_protocol::user_input::UserInput;
use core_test_support::TempDirExt;
use core_test_support::assert_regex_match;
@@ -43,7 +44,7 @@ fn assert_wall_time_header(output: &str) {
assert_eq!(marker, "Output:");
}
// Verifies that a standard tool call (shell_command) exceeding the model formatting
// Verifies that a standard tool call (exec_command) exceeding the model formatting
// limits is truncated before being sent back to the model.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tool_call_output_configured_limit_chars_type() -> Result<()> {
@@ -51,7 +52,7 @@ async fn tool_call_output_configured_limit_chars_type() -> Result<()> {
let server = start_mock_server().await;
// Use a model that exposes the shell_command tool.
// Use a model that exposes the exec_command tool.
let mut builder = test_codex().with_model("gpt-5.2").with_config(|config| {
config.tool_output_token_limit = Some(100_000);
});
@@ -65,8 +66,9 @@ async fn tool_call_output_configured_limit_chars_type() -> Result<()> {
"seq 1 100000"
};
let args = serde_json::json!({
"command": command,
"timeout_ms": 5_000,
"cmd": command,
"yield_time_ms": 5_000,
"max_output_tokens": 100_000,
});
// First response: model tells us to run the tool; second: complete the turn.
@@ -74,7 +76,7 @@ async fn tool_call_output_configured_limit_chars_type() -> Result<()> {
&server,
sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
responses::ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
responses::ev_completed("resp-1"),
]),
)
@@ -110,19 +112,20 @@ async fn tool_call_output_configured_limit_chars_type() -> Result<()> {
);
assert!(
(400000..=401000).contains(&output.len()),
"we should be almost 100k tokens"
(400_000..=401_000).contains(&output.len()),
"expected output near the configured 100k-token budget, got {} bytes",
output.len()
);
assert!(
!output.contains("tokens truncated"),
"shell output should not contain tokens truncated marker: {output}"
output.contains("chars truncated"),
"unified exec should preserve the model's byte-based truncation policy"
);
Ok(())
}
// Verifies that a standard tool call (shell_command) exceeding the model formatting
// Verifies that a standard tool call (exec_command) exceeding the model formatting
// limits is truncated before being sent back to the model.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tool_call_output_exceeds_limit_truncated_chars_limit() -> Result<()> {
@@ -130,7 +133,7 @@ async fn tool_call_output_exceeds_limit_truncated_chars_limit() -> Result<()> {
let server = start_mock_server().await;
// Use a model that exposes the shell_command tool.
// Use a model that exposes the exec_command tool.
let mut builder = test_codex().with_model("gpt-5.2");
let fixture = builder.build(&server).await?;
@@ -142,8 +145,8 @@ async fn tool_call_output_exceeds_limit_truncated_chars_limit() -> Result<()> {
"seq 1 100000"
};
let args = serde_json::json!({
"command": command,
"timeout_ms": 5_000,
"cmd": command,
"yield_time_ms": 5_000,
});
// First response: model tells us to run the tool; second: complete the turn.
@@ -151,7 +154,7 @@ async fn tool_call_output_exceeds_limit_truncated_chars_limit() -> Result<()> {
&server,
sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
responses::ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
responses::ev_completed("resp-1"),
]),
)
@@ -186,20 +189,20 @@ async fn tool_call_output_exceeds_limit_truncated_chars_limit() -> Result<()> {
"expected truncated shell output to be plain text"
);
let truncated_pattern = r#"(?s)^Exit code: 0\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nTotal output lines: 100000\nOutput:\n.*?…\d+ chars truncated….*$"#;
let truncated_pattern = r#"(?s)^Chunk ID: [^\n]+\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nProcess exited with code 0\nOriginal token count: \d+\nOutput:\nWarning: truncated output \(original token count: \d+\)\nTotal output lines: 100000\n\n.*?…\d+ chars truncated….*$"#;
assert_regex_match(truncated_pattern, &output);
let len = output.len();
assert!(
(9_900..=10_100).contains(&len),
(9_900..=10_500).contains(&len),
"expected ~10k chars after truncation, got {len}"
);
Ok(())
}
// Verifies that a standard tool call (shell_command) exceeding the model formatting
// Verifies that a standard tool call (exec_command) exceeding the model formatting
// limits is truncated before being sent back to the model.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tool_call_output_exceeds_limit_truncated_for_model() -> Result<()> {
@@ -207,7 +210,7 @@ async fn tool_call_output_exceeds_limit_truncated_for_model() -> Result<()> {
let server = start_mock_server().await;
// Use a model that exposes the shell_command tool.
// Use a model that exposes the exec_command tool.
let mut builder = test_codex().with_model("gpt-5.4");
let fixture = builder.build(&server).await?;
@@ -218,8 +221,8 @@ async fn tool_call_output_exceeds_limit_truncated_for_model() -> Result<()> {
"seq 1 100000"
};
let args = serde_json::json!({
"command": command,
"timeout_ms": 5_000,
"cmd": command,
"yield_time_ms": 5_000,
});
// First response: model tells us to run the tool; second: complete the turn.
@@ -227,7 +230,7 @@ async fn tool_call_output_exceeds_limit_truncated_for_model() -> Result<()> {
&server,
sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
responses::ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
responses::ev_completed("resp-1"),
]),
)
@@ -261,17 +264,21 @@ async fn tool_call_output_exceeds_limit_truncated_for_model() -> Result<()> {
serde_json::from_str::<Value>(&output).is_err(),
"expected truncated shell output to be plain text"
);
let truncated_pattern = r#"(?s)^Exit code: 0
let truncated_pattern = r#"(?s)^Chunk ID: [^\n]+
Wall time: [0-9]+(?:\.[0-9]+)? seconds
Total output lines: 100000
Process exited with code 0
Original token count: \d+
Output:
Warning: truncated output \(original token count: \d+\)
Total output lines: 100000
1
2
3
4
5
6
.*…137224 tokens truncated.*
.*…\d+ tokens truncated.*
99999
100000
$"#;
@@ -280,7 +287,7 @@ $"#;
Ok(())
}
// Ensures shell_command outputs that exceed the line limit are truncated only once.
// Ensures exec_command outputs that exceed the line limit are truncated only once.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tool_call_output_truncated_only_once() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -296,15 +303,15 @@ async fn tool_call_output_truncated_only_once() -> Result<()> {
"seq 1 10000"
};
let args = serde_json::json!({
"command": command,
"timeout_ms": 5_000,
"cmd": command,
"yield_time_ms": 5_000,
});
mount_sse_once(
&server,
sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
responses::ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
responses::ev_completed("resp-1"),
]),
)
@@ -585,15 +592,15 @@ async fn token_policy_marker_reports_tokens() -> Result<()> {
let call_id = "shell-token-marker";
let args = json!({
"command": "seq 1 150",
"timeout_ms": 5_000,
"cmd": "seq 1 150",
"yield_time_ms": 5_000,
});
mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
)
@@ -616,14 +623,16 @@ async fn token_policy_marker_reports_tokens() -> Result<()> {
.function_call_output_text(call_id)
.context("shell output present")?;
let pattern = r"(?s)^Exit code: 0\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nTotal output lines: 150\nOutput:\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19.*tokens truncated.*129\n130\n131\n132\n133\n134\n135\n136\n137\n138\n139\n140\n141\n142\n143\n144\n145\n146\n147\n148\n149\n150\n$";
let pattern = r"(?s)^Chunk ID: [^\n]+\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nProcess exited with code 0\nOriginal token count: \d+\nOutput:\nWarning: truncated output \(original token count: \d+\)\nTotal output lines: 150\n\n1\n2\n3\n.*…\d+ tokens truncated….*149\n150\n$";
assert_regex_match(pattern, &output);
assert_eq!(output.matches("tokens truncated").count(), 1);
assert!(output.len() <= (TruncationPolicy::Tokens(50) * 1.2).byte_budget());
Ok(())
}
// Byte-based policy should report bytes removed.
// Byte-based policy should report characters removed.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn byte_policy_marker_reports_bytes() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -636,15 +645,15 @@ async fn byte_policy_marker_reports_bytes() -> Result<()> {
let call_id = "shell-byte-marker";
let args = json!({
"command": "seq 1 150",
"timeout_ms": 5_000,
"cmd": "seq 1 150",
"yield_time_ms": 5_000,
});
mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
)
@@ -667,16 +676,18 @@ async fn byte_policy_marker_reports_bytes() -> Result<()> {
.function_call_output_text(call_id)
.context("shell output present")?;
let pattern = r"(?s)^Exit code: 0\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nTotal output lines: 150\nOutput:\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19.*chars truncated.*129\n130\n131\n132\n133\n134\n135\n136\n137\n138\n139\n140\n141\n142\n143\n144\n145\n146\n147\n148\n149\n150\n$";
let pattern = r"(?s)^Chunk ID: [^\n]+\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nProcess exited with code 0\nOriginal token count: \d+\nOutput:\nWarning: truncated output \(original token count: \d+\)\nTotal output lines: 150\n\n1\n2\n3\n.*…\d+ chars truncated….*149\n150\n$";
assert_regex_match(pattern, &output);
assert_eq!(output.matches("chars truncated").count(), 1);
assert!(output.len() <= (TruncationPolicy::Bytes(200) * 1.2).byte_budget());
Ok(())
}
// shell_command output should remain intact when the config opts into a large token budget.
// exec_command output should remain intact when the config opts into a large token budget.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_command_output_not_truncated_with_custom_limit() -> Result<()> {
async fn exec_command_output_not_truncated_with_custom_limit() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
@@ -687,8 +698,8 @@ async fn shell_command_output_not_truncated_with_custom_limit() -> Result<()> {
let call_id = "shell-no-trunc";
let args = json!({
"command": "seq 1 1000",
"timeout_ms": 5_000,
"cmd": "seq 1 1000",
"yield_time_ms": 5_000,
});
let expected_body: String = (1..=1000).map(|i| format!("{i}\n")).collect();
@@ -696,7 +707,7 @@ async fn shell_command_output_not_truncated_with_custom_limit() -> Result<()> {
&server,
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
)
+5 -5
View File
@@ -4,9 +4,9 @@ use anyhow::Result;
use core_test_support::responses::WebSocketConnectionConfig;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_exec_command_call;
use core_test_support::responses::ev_reasoning_item;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::ev_shell_command_call;
use core_test_support::responses::mount_response_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::sse_response;
@@ -30,7 +30,7 @@ async fn responses_turn_state_persists_within_turn_and_resets_after() -> Result<
let first_response = sse(vec![
ev_response_created("resp-1"),
ev_reasoning_item("rsn-1", &["thinking"], &[]),
ev_shell_command_call(call_id, "echo turn-state"),
ev_exec_command_call(call_id, "echo turn-state"),
ev_completed("resp-1"),
]);
let second_response = sse(vec![
@@ -102,7 +102,7 @@ async fn websocket_turn_state_persists_within_turn_and_resets_after() -> Result<
}),
ev_response_created("resp-1"),
ev_reasoning_item("rsn-1", &["thinking"], &[]),
ev_shell_command_call("ws-shell-turn-state", "echo websocket"),
ev_exec_command_call("ws-shell-turn-state", "echo websocket"),
ev_completed("resp-1"),
],
vec![
@@ -209,7 +209,7 @@ async fn websocket_turn_state_is_stable_within_turn() -> Result<()> {
"headers": {(TURN_STATE_HEADER): "ts-1"},
}),
ev_response_created("resp-1"),
ev_shell_command_call("ws-shell-1", "echo one"),
ev_exec_command_call("ws-shell-1", "echo one"),
ev_completed("resp-1"),
],
vec![
@@ -218,7 +218,7 @@ async fn websocket_turn_state_is_stable_within_turn() -> Result<()> {
"headers": {(TURN_STATE_HEADER): "ts-2"},
}),
ev_response_created("resp-2"),
ev_shell_command_call("ws-shell-2", "echo two"),
ev_exec_command_call("ws-shell-2", "echo two"),
ev_completed("resp-2"),
],
vec![
+59 -2
View File
@@ -26,6 +26,7 @@ use codex_protocol::protocol::ExecCommandSource;
use codex_protocol::protocol::ExecCommandStatus;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::shell_environment::CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR;
use codex_protocol::user_input::UserInput;
use codex_utils_output_truncation::approx_tokens_from_byte_count;
use codex_utils_path_uri::PathUri;
@@ -304,6 +305,60 @@ async fn exec_command_hides_and_rejects_login_when_disabled() -> Result<()> {
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(()));
skip_if_wine_exec!(
Ok(()),
"basic PowerShell execution through Wine is unavailable"
);
let builder = test_codex().with_model("gpt-5.4").with_config(|config| {
config.permissions.shell_environment_policy.r#set.insert(
CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR.to_string(),
"configured-noise-token".to_string(),
);
config.permissions.shell_environment_policy.r#set.insert(
CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR.to_ascii_lowercase(),
"case-variant-noise-token".to_string(),
);
});
let harness = TestCodexHarness::with_auto_env_builder(builder).await?;
let command = match core_test_support::test_target_os() {
core_test_support::TestTargetOs::Linux | core_test_support::TestTargetOs::MacOs => {
"if [ -n \"${CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN:-}\" ] || [ -n \"${codex_exec_server_noise_auth_token:-}\" ]; then echo leaked; else echo unset; fi"
}
core_test_support::TestTargetOs::Windows => {
"if ($env:CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN) { Write-Output leaked } else { Write-Output unset }"
}
};
let call_id = "exec-command-noise-auth-token";
let arguments = json!({ "cmd": command, "yield_time_ms": 5_000 });
mount_sse_sequence(
harness.server(),
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&arguments)?),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
],
)
.await;
harness
.submit("check the remote execution auth token")
.await?;
let output = parse_unified_exec_output(&harness.function_call_stdout(call_id).await)?;
assert_eq!(output.output.trim(), "unset");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_command_uses_installed_environment_shell_policy_with_explicit_overrides() -> Result<()>
{
@@ -1821,9 +1876,10 @@ async fn exec_command_clamps_model_requested_max_output_tokens_to_policy() -> Re
assert_eq!(output.original_token_count, Some(8_991));
let output_text = output.output.replace("\r\n", "\n");
assert_regex_match(
r"^Warning: truncated output \(original token count: 8991\)\nTotal output lines: 999\n\nEXEC-LINE-0001 x{20}\nEXEC-LINE-0002 x{20}\nEXEC-LINE-0003 x{13}…8941 tokens truncated…E-0997 x{20}\nEXEC-LINE-0998 x{20}\nEXEC-LINE-0999 x{20}\n$",
r"(?s)^Warning: truncated output \(original token count: 8991\)\nTotal output lines: 999\n\nEXEC-LINE-.*…\d+ tokens truncated…x+\n$",
&output_text,
);
assert_eq!(output_text.matches("tokens truncated").count(), 1);
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
@@ -1911,9 +1967,10 @@ async fn write_stdin_clamps_model_requested_max_output_tokens_to_policy() -> Res
assert_eq!(stdin_output.original_token_count, Some(9_492));
let stdin_output_text = stdin_output.output.replace("\r\n", "\n");
assert_regex_match(
r"^Warning: truncated output \(original token count: 9492\)\nTotal output lines: 1000\n\ngo\nSTDIN-LINE-0001 y{20}\nSTDIN-LINE-0002 y{20}\nSTDIN-LINE-0003 yyyy…9442 tokens truncated…7 y{20}\nSTDIN-LINE-0998 y{20}\nSTDIN-LINE-0999 y{20}\n$",
r"(?s)^Warning: truncated output \(original token count: 9492\)\nTotal output lines: 1000\n\ngo\nSTDIN.*…\d+ tokens truncated…y+\n$",
&stdin_output_text,
);
assert_eq!(stdin_output_text.matches("tokens truncated").count(), 1);
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
+16 -14
View File
@@ -189,18 +189,18 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()>
let call_id = "active-turn-shell-call";
let args = if cfg!(windows) {
serde_json::json!({
"command": "Start-Sleep -Seconds 2; Write-Output model-shell",
"timeout_ms": 10_000,
"cmd": "Start-Sleep -Seconds 2; Write-Output model-shell",
"yield_time_ms": 10_000,
})
} else {
serde_json::json!({
"command": "sleep 2; echo model-shell",
"timeout_ms": 10_000,
"cmd": "sleep 2; echo model-shell",
"yield_time_ms": 10_000,
})
};
let first = sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]);
let second = sse(vec![
@@ -239,7 +239,9 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()>
.await?;
let _ = wait_for_event_match(&fixture.codex, |ev| match ev {
EventMsg::ExecCommandBegin(event) if event.source == ExecCommandSource::Agent => {
EventMsg::ExecCommandBegin(event)
if event.source == ExecCommandSource::UnifiedExecStartup =>
{
Some(event.clone())
}
_ => None,
@@ -505,13 +507,13 @@ async fn user_shell_command_is_truncated_only_once() -> anyhow::Result<()> {
let call_id = "user-shell-double-truncation";
let args = if cfg!(windows) {
serde_json::json!({
"command": "for ($i=1; $i -le 2000; $i++) { Write-Output $i }",
"timeout_ms": 5_000,
"cmd": "for ($i=1; $i -le 2000; $i++) { Write-Output $i }",
"yield_time_ms": 5_000,
})
} else {
serde_json::json!({
"command": "seq 1 2000",
"timeout_ms": 5_000,
"cmd": "seq 1 2000",
"yield_time_ms": 5_000,
})
};
@@ -519,7 +521,7 @@ async fn user_shell_command_is_truncated_only_once() -> anyhow::Result<()> {
&server,
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
)
@@ -535,7 +537,7 @@ async fn user_shell_command_is_truncated_only_once() -> anyhow::Result<()> {
fixture
.submit_turn_with_permission_profile(
"trigger big shell_command output",
"trigger big exec_command output",
PermissionProfile::Disabled,
)
.await?;
@@ -543,13 +545,13 @@ async fn user_shell_command_is_truncated_only_once() -> anyhow::Result<()> {
let output = mock2
.single_request()
.function_call_output_text(call_id)
.context("function_call_output present for shell_command call")?;
.context("function_call_output present for exec_command call")?;
let truncation_headers = output.matches("Total output lines:").count();
assert_eq!(
truncation_headers, 1,
"shell_command output should carry only one truncation header: {output}"
"exec_command output should carry only one truncation header: {output}"
);
Ok(())
+1 -1
View File
@@ -1613,7 +1613,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an
effort: ReasoningEffort::Medium,
description: ReasoningEffort::Medium.to_string(),
}],
shell_type: ConfigShellToolType::ShellCommand,
shell_type: ConfigShellToolType::UnifiedExec,
visibility: ModelVisibility::List,
supported_in_api: true,
input_modalities: vec![InputModality::Text],
+24 -45
View File
@@ -396,8 +396,7 @@ async fn windows_elevated_enforces_deny_read_and_protects_setup_marker() -> anyh
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial(codex_home)]
async fn windows_elevated_shell_and_unified_exec_enforce_managed_deny_reads() -> anyhow::Result<()>
{
async fn windows_elevated_unified_exec_enforces_managed_deny_reads() -> anyhow::Result<()> {
let codex_home =
codex_home_for_windows_sandbox_test("windows-elevated-tool-runtime-deny-read-codex-home")?;
let _codex_home_guard = EnvVarGuard::set("CODEX_HOME", codex_home.path().as_os_str());
@@ -471,13 +470,7 @@ async fn windows_elevated_shell_and_unified_exec_enforce_managed_deny_reads() ->
"(type exact-secret.txt 1>NUL 2>NUL && echo EXACT-READ || echo EXACT-DENIED) & ",
"type public.txt"
);
let shell_call_id = "windows-managed-deny-read-shell-command";
let unified_call_id = "windows-managed-deny-read-exec-command";
let shell_args = json!({
"command": command,
"timeout_ms": 30_000,
"login": false,
});
let call_id = "windows-managed-deny-read-exec-command";
let unified_args = json!({
"cmd": command,
"yield_time_ms": 30_000,
@@ -487,19 +480,10 @@ async fn windows_elevated_shell_and_unified_exec_enforce_managed_deny_reads() ->
mount_sse_sequence(
harness.server(),
vec![
sse(vec![
ev_response_created("resp-windows-shell-deny-read"),
ev_function_call(
shell_call_id,
"shell_command",
&serde_json::to_string(&shell_args)?,
),
ev_completed("resp-windows-shell-deny-read"),
]),
sse(vec![
ev_response_created("resp-windows-unified-deny-read"),
ev_function_call(
unified_call_id,
call_id,
"exec_command",
&serde_json::to_string(&unified_args)?,
),
@@ -522,32 +506,27 @@ async fn windows_elevated_shell_and_unified_exec_enforce_managed_deny_reads() ->
.submit_with_permission_profile("read the sandbox fixtures", permission_profile)
.await?;
for (tool_name, call_id) in [
("shell_command", shell_call_id),
("exec_command", unified_call_id),
] {
let output = harness.function_call_stdout(call_id).await;
assert!(
output.contains("GLOB-DENIED"),
"{tool_name} should reject glob-denied reads: {output:?}"
);
assert!(
output.contains("EXACT-DENIED"),
"{tool_name} should reject exact-path-denied reads: {output:?}"
);
assert!(
output.contains("public ok"),
"{tool_name} should preserve allowed reads: {output:?}"
);
assert!(
!output.contains("GLOB-READ") && !output.contains("glob secret"),
"{tool_name} leaked glob-denied file contents: {output:?}"
);
assert!(
!output.contains("EXACT-READ") && !output.contains("exact secret"),
"{tool_name} leaked exact-path-denied file contents: {output:?}"
);
}
let output = harness.function_call_stdout(call_id).await;
assert!(
output.contains("GLOB-DENIED"),
"exec_command should reject glob-denied reads: {output:?}"
);
assert!(
output.contains("EXACT-DENIED"),
"exec_command should reject exact-path-denied reads: {output:?}"
);
assert!(
output.contains("public ok"),
"exec_command should preserve allowed reads: {output:?}"
);
assert!(
!output.contains("GLOB-READ") && !output.contains("glob secret"),
"exec_command leaked glob-denied file contents: {output:?}"
);
assert!(
!output.contains("EXACT-READ") && !output.contains("exact secret"),
"exec_command leaked exact-path-denied file contents: {output:?}"
);
Ok(())
}

Some files were not shown because too many files have changed in this diff Show More