[codex] Remove unused legacy shell tools (#22246)

## Why

Recent session history showed no active use of the raw `shell`,
`local_shell`, or `container.exec` execution surfaces. Keeping those
handlers/specs wired into core leaves duplicate shell execution paths
alongside the supported `shell_command` and unified exec tools.

## What changed

- Removed the raw `shell` handler/spec and its `ShellToolCallParams`
protocol helper.
- Removed the legacy `local_shell` and `container.exec` handler/spec
plumbing while preserving persisted-history compatibility for old
response items.
- Normalized model/config `default` and `local` shell selections to
`shell_command`.
- Pruned tests that exercised removed raw-shell/local-shell/apply-patch
variants and kept coverage on `shell_command`, unified exec, and
freeform `apply_patch`.

## Verification

- `git diff --check`
- `cargo test -p codex-protocol`
- `cargo test -p codex-tools`
- `cargo test -p codex-core tools::handlers::shell`
- `cargo test -p codex-core tools::spec`
- `cargo test -p codex-core tools::router`
- `cargo test -p codex-core
active_call_preserves_triggering_command_context`
- `cargo test -p codex-core guardian_tests`
- `cargo test -p codex-core --test all shell_serialization`
- `cargo test -p codex-core --test all apply_patch_cli`
- `cargo test -p codex-core --test all shell_command_`
- `cargo test -p codex-core --test all local_shell`
- `cargo test -p codex-core --test all otel::`
- `cargo test -p codex-core --test all hooks::`
- `just fix -p codex-core`
- `just fix -p codex-tools`
This commit is contained in:
pakrym-oai
2026-05-13 16:43:25 +00:00
committed by GitHub
parent 7c7b4861d8
commit 83decfa300
47 changed files with 205 additions and 1981 deletions
-19
View File
@@ -890,10 +890,6 @@ pub fn ev_apply_patch_call(
) -> Value {
match output_type {
ApplyPatchModelOutput::Freeform => ev_apply_patch_custom_tool_call(call_id, patch),
ApplyPatchModelOutput::Shell => ev_apply_patch_shell_call(call_id, patch),
ApplyPatchModelOutput::ShellViaHeredoc => {
ev_apply_patch_shell_call_via_heredoc(call_id, patch)
}
ApplyPatchModelOutput::ShellCommandViaHeredoc => {
ev_apply_patch_shell_command_call_via_heredoc(call_id, patch)
}
@@ -925,21 +921,6 @@ pub fn ev_shell_command_call_with_args(call_id: &str, args: &serde_json::Value)
ev_function_call(call_id, "shell_command", &arguments)
}
pub fn ev_apply_patch_shell_call(call_id: &str, patch: &str) -> Value {
let args = serde_json::json!({ "command": ["apply_patch", patch] });
let arguments = serde_json::to_string(&args).expect("serialize apply_patch arguments");
ev_function_call(call_id, "shell", &arguments)
}
pub fn ev_apply_patch_shell_call_via_heredoc(call_id: &str, patch: &str) -> Value {
let script = format!("apply_patch <<'EOF'\n{patch}\nEOF\n");
let args = serde_json::json!({ "command": ["bash", "-lc", script] });
let arguments = serde_json::to_string(&args).expect("serialize apply_patch arguments");
ev_function_call(call_id, "shell", &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") });
let arguments = serde_json::to_string(&args).expect("serialize apply_patch arguments");
+1 -7
View File
@@ -189,17 +189,13 @@ fn docker_command_capture_stdout<const N: usize>(args: [&str; N]) -> Result<Stri
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ApplyPatchModelOutput {
Freeform,
Shell,
ShellViaHeredoc,
ShellCommandViaHeredoc,
}
/// A collection of different ways the model can output an apply_patch call
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ShellModelOutput {
Shell,
ShellCommand,
LocalShell,
// UnifiedExec has its own set of tests
}
@@ -959,9 +955,7 @@ impl TestCodexHarness {
ApplyPatchModelOutput::Freeform => {
Box::pin(self.custom_tool_call_output(call_id)).await
}
ApplyPatchModelOutput::Shell
| ApplyPatchModelOutput::ShellViaHeredoc
| ApplyPatchModelOutput::ShellCommandViaHeredoc => {
ApplyPatchModelOutput::ShellCommandViaHeredoc => {
Box::pin(self.function_call_stdout(call_id)).await
}
}
@@ -258,8 +258,6 @@ async fn apply_patch_cli_uses_codex_self_exe_with_linux_sandbox_helper_alias() -
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Shell)]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
async fn apply_patch_cli_multiple_operations_integration(
output_type: ApplyPatchModelOutput,
) -> Result<()> {
@@ -302,8 +300,6 @@ D delete.txt
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Shell)]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
async fn apply_patch_cli_multiple_chunks(model_output: ApplyPatchModelOutput) -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -329,8 +325,6 @@ async fn apply_patch_cli_multiple_chunks(model_output: ApplyPatchModelOutput) ->
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Shell)]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
async fn apply_patch_cli_moves_file_to_new_directory(
model_output: ApplyPatchModelOutput,
@@ -357,8 +351,6 @@ async fn apply_patch_cli_moves_file_to_new_directory(
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Shell)]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
async fn apply_patch_cli_updates_file_appends_trailing_newline(
model_output: ApplyPatchModelOutput,
@@ -385,8 +377,6 @@ async fn apply_patch_cli_updates_file_appends_trailing_newline(
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Shell)]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
async fn apply_patch_cli_insert_only_hunk_modifies_file(
model_output: ApplyPatchModelOutput,
@@ -414,8 +404,6 @@ async fn apply_patch_cli_insert_only_hunk_modifies_file(
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Shell)]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
async fn apply_patch_cli_move_overwrites_existing_destination(
model_output: ApplyPatchModelOutput,
@@ -445,8 +433,6 @@ async fn apply_patch_cli_move_overwrites_existing_destination(
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Shell)]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
async fn apply_patch_cli_move_without_content_change_has_no_turn_diff(
model_output: ApplyPatchModelOutput,
@@ -484,8 +470,6 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff(
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Shell)]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
async fn apply_patch_cli_add_overwrites_existing_file(
model_output: ApplyPatchModelOutput,
@@ -511,8 +495,6 @@ async fn apply_patch_cli_add_overwrites_existing_file(
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Shell)]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
async fn apply_patch_cli_rejects_invalid_hunk_header(
model_output: ApplyPatchModelOutput,
@@ -542,8 +524,6 @@ async fn apply_patch_cli_rejects_invalid_hunk_header(
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Shell)]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
async fn apply_patch_cli_reports_missing_context(
model_output: ApplyPatchModelOutput,
@@ -577,8 +557,6 @@ async fn apply_patch_cli_reports_missing_context(
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Shell)]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
async fn apply_patch_cli_reports_missing_target_file(
model_output: ApplyPatchModelOutput,
@@ -612,8 +590,6 @@ async fn apply_patch_cli_reports_missing_target_file(
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Shell)]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
async fn apply_patch_cli_delete_missing_file_reports_error(
model_output: ApplyPatchModelOutput,
@@ -648,8 +624,6 @@ async fn apply_patch_cli_delete_missing_file_reports_error(
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Shell)]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
async fn apply_patch_cli_rejects_empty_patch(model_output: ApplyPatchModelOutput) -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -672,8 +646,6 @@ async fn apply_patch_cli_rejects_empty_patch(model_output: ApplyPatchModelOutput
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Shell)]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
async fn apply_patch_cli_delete_directory_reports_verification_error(
model_output: ApplyPatchModelOutput,
@@ -698,8 +670,6 @@ async fn apply_patch_cli_delete_directory_reports_verification_error(
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Shell)]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
async fn apply_patch_cli_rejects_path_traversal_outside_workspace(
model_output: ApplyPatchModelOutput,
@@ -744,8 +714,6 @@ async fn apply_patch_cli_rejects_path_traversal_outside_workspace(
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Shell ; "shell")]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc ; "shell_heredoc")]
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc ; "shell_command_heredoc")]
async fn intercepted_apply_patch_verification_uses_local_sandbox(
model_output: ApplyPatchModelOutput,
@@ -797,8 +765,6 @@ async fn intercepted_apply_patch_verification_uses_local_sandbox(
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform ; "freeform")]
#[test_case(ApplyPatchModelOutput::Shell ; "shell")]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc ; "shell_heredoc")]
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc ; "shell_command_heredoc")]
async fn apply_patch_cli_does_not_write_through_symlink_escape_outside_workspace(
model_output: ApplyPatchModelOutput,
@@ -868,8 +834,6 @@ async fn apply_patch_cli_does_not_write_through_symlink_escape_outside_workspace
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform ; "freeform")]
#[test_case(ApplyPatchModelOutput::Shell ; "shell")]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc ; "shell_heredoc")]
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc ; "shell_command_heredoc")]
async fn apply_patch_cli_preserves_existing_hard_link_outside_workspace(
model_output: ApplyPatchModelOutput,
@@ -972,8 +936,6 @@ async fn apply_patch_cli_preserves_existing_hard_link_outside_workspace(
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Shell)]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
async fn apply_patch_cli_rejects_move_path_traversal_outside_workspace(
model_output: ApplyPatchModelOutput,
@@ -1021,8 +983,6 @@ async fn apply_patch_cli_rejects_move_path_traversal_outside_workspace(
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Shell)]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
async fn apply_patch_cli_verification_failure_has_no_side_effects(
model_output: ApplyPatchModelOutput,
@@ -1535,7 +1495,6 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() ->
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
async fn apply_patch_shell_accepts_lenient_heredoc_wrapped_patch(
model_output: ApplyPatchModelOutput,
@@ -1558,8 +1517,6 @@ async fn apply_patch_shell_accepts_lenient_heredoc_wrapped_patch(
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Shell)]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
async fn apply_patch_cli_end_of_file_anchor(model_output: ApplyPatchModelOutput) -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1579,8 +1536,6 @@ async fn apply_patch_cli_end_of_file_anchor(model_output: ApplyPatchModelOutput)
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Shell)]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
async fn apply_patch_cli_missing_second_chunk_context_rejected(
model_output: ApplyPatchModelOutput,
@@ -1615,8 +1570,6 @@ async fn apply_patch_cli_missing_second_chunk_context_rejected(
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Shell)]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
async fn apply_patch_emits_turn_diff_event_with_unified_diff(
model_output: ApplyPatchModelOutput,
@@ -1847,8 +1800,6 @@ async fn apply_patch_clears_aggregated_diff_after_inexact_delta() -> Result<()>
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Shell)]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
async fn apply_patch_change_context_disambiguates_target(
model_output: ApplyPatchModelOutput,
+20 -43
View File
@@ -26,7 +26,6 @@ use core_test_support::context_snapshot;
use core_test_support::context_snapshot::ContextSnapshotOptions;
use core_test_support::context_snapshot::ContextSnapshotRenderMode;
use core_test_support::hooks::trust_discovered_hooks;
use core_test_support::responses::ev_local_shell_call;
use core_test_support::responses::ev_reasoning_item;
use core_test_support::responses::mount_models_once;
use core_test_support::skip_if_no_network;
@@ -78,6 +77,14 @@ const PRETURN_CONTEXT_DIFF_CWD: &str = "/tmp/PRETURN_CONTEXT_DIFF_CWD";
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 {
ev_function_call(
call_id,
"shell_command",
&json!({ "command": command }).to_string(),
)
}
fn disabled_permission_user_turn(text: impl Into<String>, cwd: PathBuf, model: String) -> Op {
let (sandbox_policy, permission_profile) =
turn_permission_fields(PermissionProfile::Disabled, cwd.as_path());
@@ -954,7 +961,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_local_shell_call("r1-shell", "completed", vec!["echo", "make-react"]),
ev_shell_command_call("r1-shell", "echo make-react"),
ev_completed_with_tokens("r1", token_count_used),
]);
@@ -972,7 +979,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_local_shell_call("r3-shell", "completed", vec!["echo", "make-node"]),
ev_shell_command_call("r3-shell", "echo make-node"),
ev_completed_with_tokens("r3", token_count_used),
]);
@@ -990,7 +997,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_local_shell_call("r6-shell", "completed", vec!["echo", "make-python"]),
ev_shell_command_call("r6-shell", "echo make-python"),
ev_completed_with_tokens("r6", token_count_used),
]);
@@ -1186,20 +1193,10 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() {
"type": "reasoning"
},
{
"action": {
"command": [
"echo",
"make-react"
],
"env": null,
"timeout_ms": null,
"type": "exec",
"user": null,
"working_directory": null
},
"arguments": "{\"command\":\"echo make-react\"}",
"call_id": "r1-shell",
"status": "completed",
"type": "local_shell_call"
"name": "shell_command",
"type": "function_call"
},
{
"call_id": "r1-shell",
@@ -1296,20 +1293,10 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() {
"type": "reasoning"
},
{
"action": {
"command": [
"echo",
"make-node"
],
"env": null,
"timeout_ms": null,
"type": "exec",
"user": null,
"working_directory": null
},
"arguments": "{\"command\":\"echo make-node\"}",
"call_id": "r3-shell",
"status": "completed",
"type": "local_shell_call"
"name": "shell_command",
"type": "function_call"
},
{
"call_id": "r3-shell",
@@ -1406,20 +1393,10 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() {
"type": "reasoning"
},
{
"action": {
"command": [
"echo",
"make-python"
],
"env": null,
"timeout_ms": null,
"type": "exec",
"user": null,
"working_directory": null
},
"arguments": "{\"command\":\"echo make-python\"}",
"call_id": "r6-shell",
"status": "completed",
"type": "local_shell_call"
"name": "shell_command",
"type": "function_call"
},
{
"call_id": "r6-shell",
+4 -5
View File
@@ -479,10 +479,9 @@ async fn assert_remote_manual_compact_request_parity(
responses::ev_completed("turn-three-final-response"),
]),
responses::sse(vec![
responses::ev_local_shell_call(
"turn-four-local-shell",
"completed",
vec!["/bin/echo", "TURN_FOUR_LOCAL_SHELL"],
responses::ev_shell_command_call(
"turn-four-shell-command",
"echo TURN_FOUR_LOCAL_SHELL",
),
responses::ev_completed("turn-four-local-shell-response"),
]),
@@ -589,7 +588,7 @@ async fn assert_remote_manual_compact_request_parity(
assert_eq!(
response_requests.len(),
7,
"expected five turns with one unsupported tool continuation and one local shell continuation"
"expected five turns with one unsupported tool continuation and one shell command continuation"
);
assert_eq!(
compact_mock.requests().len(),
+8 -218
View File
@@ -2135,48 +2135,25 @@ async fn blocked_pre_tool_use_records_additional_context_for_shell_command() ->
#[derive(Clone, Copy)]
enum BashRewriteSurface {
ContainerExec,
ExecCommand,
LocalShell,
Shell,
ShellCommand,
}
impl BashRewriteSurface {
fn slug(self) -> &'static str {
match self {
BashRewriteSurface::ContainerExec => "container-exec",
BashRewriteSurface::ExecCommand => "exec-command",
BashRewriteSurface::LocalShell => "local-shell",
BashRewriteSurface::Shell => "shell",
BashRewriteSurface::ShellCommand => "shell-command",
}
}
fn tool_call(self, call_id: &str, command: &[String], command_text: &str) -> Result<Value> {
fn tool_call(self, call_id: &str, command_text: &str) -> Result<Value> {
match self {
BashRewriteSurface::ContainerExec => Ok(ev_function_call(
call_id,
"container.exec",
&serde_json::to_string(&serde_json::json!({ "command": command }))?,
)),
BashRewriteSurface::ExecCommand => Ok(ev_function_call(
call_id,
"exec_command",
&serde_json::to_string(&serde_json::json!({ "cmd": command_text }))?,
)),
BashRewriteSurface::LocalShell => {
Ok(core_test_support::responses::ev_local_shell_call(
call_id,
"completed",
command.iter().map(String::as_str).collect(),
))
}
BashRewriteSurface::Shell => Ok(ev_function_call(
call_id,
"shell",
&serde_json::to_string(&serde_json::json!({ "command": command }))?,
)),
BashRewriteSurface::ShellCommand => Ok(ev_function_call(
call_id,
"shell_command",
@@ -2185,33 +2162,19 @@ impl BashRewriteSurface {
}
}
fn original_command(self, marker: &Path) -> (Vec<String>, String) {
let command_text = format!("printf original > {}", marker.display());
fn original_command(self, marker: &Path) -> String {
match self {
BashRewriteSurface::ContainerExec
| BashRewriteSurface::LocalShell
| BashRewriteSurface::Shell => {
let command = vec!["/bin/sh".to_string(), "-c".to_string(), command_text];
let command_text = codex_shell_command::parse_command::shlex_join(&command);
(command, command_text)
}
BashRewriteSurface::ExecCommand | BashRewriteSurface::ShellCommand => {
(Vec::new(), command_text)
format!("printf original > {}", marker.display())
}
}
}
fn rewritten_command(self, marker: &Path) -> String {
let command_text = format!("printf rewritten > {}", marker.display());
match self {
BashRewriteSurface::ContainerExec
| BashRewriteSurface::LocalShell
| BashRewriteSurface::Shell => codex_shell_command::parse_command::shlex_join(&[
"/bin/sh".to_string(),
"-c".to_string(),
command_text,
]),
BashRewriteSurface::ExecCommand | BashRewriteSurface::ShellCommand => command_text,
BashRewriteSurface::ExecCommand | BashRewriteSurface::ShellCommand => {
format!("printf rewritten > {}", marker.display())
}
}
}
@@ -2234,14 +2197,14 @@ async fn assert_pre_tool_use_rewrites_bash_surface(surface: BashRewriteSurface)
let call_id = format!("pretooluse-{slug}-rewrite");
let original_marker = std::env::temp_dir().join(format!("pretooluse-{slug}-original-marker"));
let rewritten_marker = std::env::temp_dir().join(format!("pretooluse-{slug}-rewritten-marker"));
let (tool_command, original_command) = surface.original_command(&original_marker);
let original_command = surface.original_command(&original_marker);
let rewritten_command = surface.rewritten_command(&rewritten_marker);
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
surface.tool_call(&call_id, &tool_command, &original_command)?,
surface.tool_call(&call_id, &original_command)?,
ev_completed("resp-1"),
]),
sse(vec![
@@ -2295,21 +2258,6 @@ async fn assert_pre_tool_use_rewrites_bash_surface(surface: BashRewriteSurface)
Ok(())
}
#[tokio::test]
async fn pre_tool_use_rewrites_shell_before_execution() -> Result<()> {
assert_pre_tool_use_rewrites_bash_surface(BashRewriteSurface::Shell).await
}
#[tokio::test]
async fn pre_tool_use_rewrites_container_exec_before_execution() -> Result<()> {
assert_pre_tool_use_rewrites_bash_surface(BashRewriteSurface::ContainerExec).await
}
#[tokio::test]
async fn pre_tool_use_rewrites_local_shell_before_execution() -> Result<()> {
assert_pre_tool_use_rewrites_bash_surface(BashRewriteSurface::LocalShell).await
}
#[tokio::test]
async fn pre_tool_use_rewrites_shell_command_before_execution() -> Result<()> {
assert_pre_tool_use_rewrites_bash_surface(BashRewriteSurface::ShellCommand).await
@@ -2745,95 +2693,6 @@ async fn pre_tool_use_merges_hooks_json_and_config_toml() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn pre_tool_use_blocks_local_shell_before_execution() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "pretooluse-local-shell";
let marker = std::env::temp_dir().join("pretooluse-local-shell-marker");
let command = vec![
"/bin/sh".to_string(),
"-c".to_string(),
format!("printf blocked > {}", marker.display()),
];
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
core_test_support::responses::ev_local_shell_call(
call_id,
"completed",
command.iter().map(String::as_str).collect(),
),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "local shell blocked"),
ev_completed("resp-2"),
]),
],
)
.await;
let mut builder = test_codex()
.with_pre_build_hook(|home| {
if let Err(error) =
write_pre_tool_use_hook(home, Some("^Bash$"), "json_deny", "blocked local shell")
{
panic!("failed to write pre tool use hook test fixture: {error}");
}
})
.with_config(trust_discovered_hooks);
let test = builder.build(&server).await?;
if marker.exists() {
fs::remove_file(&marker).context("remove leftover local shell marker")?;
}
test.submit_turn("run the blocked local shell command")
.await?;
let requests = responses.requests();
assert_eq!(requests.len(), 2);
let output_item = requests[1].function_call_output(call_id);
let output = output_item
.get("output")
.and_then(Value::as_str)
.expect("local shell output string");
assert!(
output.contains("Command blocked by PreToolUse hook: blocked local shell"),
"blocked local shell output should surface the hook reason",
);
assert!(
output.contains(&format!(
"Command: {}",
codex_shell_command::parse_command::shlex_join(&command)
)),
"blocked local shell output should surface the blocked command",
);
assert!(
!marker.exists(),
"blocked local shell command should not execute"
);
let hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?;
assert_eq!(hook_inputs.len(), 1);
assert_eq!(
hook_inputs[0]["tool_input"]["command"],
codex_shell_command::parse_command::shlex_join(&command),
);
assert!(
hook_inputs[0]["turn_id"]
.as_str()
.is_some_and(|turn_id| !turn_id.is_empty())
);
Ok(())
}
#[tokio::test]
async fn pre_tool_use_blocks_exec_command_before_execution() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -3424,75 +3283,6 @@ async fn post_tool_use_continue_false_replaces_shell_command_output_with_stop_re
Ok(())
}
#[tokio::test]
async fn post_tool_use_records_additional_context_for_local_shell() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "posttooluse-local-shell";
let command = vec![
"/bin/sh".to_string(),
"-c".to_string(),
"printf local-post-tool-output".to_string(),
];
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
core_test_support::responses::ev_local_shell_call(
call_id,
"completed",
command.iter().map(String::as_str).collect(),
),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "local shell post hook context observed"),
ev_completed("resp-2"),
]),
],
)
.await;
let post_context = "Remember the local shell post-tool note.";
let mut builder = test_codex()
.with_pre_build_hook(|home| {
if let Err(error) =
write_post_tool_use_hook(home, Some("^Bash$"), "context", post_context)
{
panic!("failed to write post tool use hook test fixture: {error}");
}
})
.with_config(trust_discovered_hooks);
let test = builder.build(&server).await?;
test.submit_turn("run the local shell command with post hook")
.await?;
let requests = responses.requests();
assert_eq!(requests.len(), 2);
assert!(
requests[1]
.message_input_texts("developer")
.contains(&post_context.to_string()),
"follow-up request should include local shell post tool use additional context",
);
let hook_inputs = read_post_tool_use_hook_inputs(test.codex_home_path())?;
assert_eq!(hook_inputs.len(), 1);
assert_eq!(
hook_inputs[0]["tool_input"]["command"],
codex_shell_command::parse_command::shlex_join(&command),
);
assert_eq!(
hook_inputs[0]["tool_response"],
Value::String("local-post-tool-output".to_string()),
);
Ok(())
}
#[tokio::test]
async fn post_tool_use_exit_two_replaces_one_shot_exec_command_output_with_feedback() -> Result<()>
{
@@ -15,8 +15,8 @@ use codex_protocol::user_input::UserInput;
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_local_shell_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;
@@ -32,7 +32,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 = "local-shell-call-1";
const CALL_ID: &str = "shell-command-call-1";
let server = MockServer::start().await;
@@ -81,7 +81,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_local_shell_call(CALL_ID, "completed", vec!["/bin/echo", "etag ok"]),
ev_shell_command_call(CALL_ID, "/bin/echo 'etag ok'"),
ev_completed("resp-1"),
]);
responses::mount_response_once(
+44 -133
View File
@@ -11,7 +11,6 @@ use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_custom_tool_call;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_local_shell_call;
use core_test_support::responses::ev_message_item_added;
use core_test_support::responses::ev_output_text_delta;
use core_test_support::responses::ev_reasoning_item;
@@ -72,6 +71,19 @@ 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 touch_command(path: &str) -> String {
if cfg!(windows) {
format!("New-Item -ItemType File -Path {path} -Force | Out-Null")
} else {
format!("/usr/bin/touch {path}")
}
}
#[test]
fn extract_log_field_handles_empty_bare_values() {
let line = "event.name=\"codex.tool_result\" mcp_server= mcp_server_origin=";
@@ -996,23 +1008,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_local_shell_missing_ids() {
async fn handle_response_item_records_tool_result_for_shell_command_call() {
let server = start_mock_server().await;
mount_sse_once(
&server,
sse(vec![
serde_json::json!({
"type": "response.output_item.done",
"item": {
"type": "local_shell_call",
"status": "completed",
"action": {
"type": "exec",
"command": vec!["/bin/echo", "hello"],
}
}
}),
shell_command_call("shell-call", "echo shell"),
ev_completed("done"),
]),
)
@@ -1021,76 +1023,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids()
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("done"),
]),
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(move |config| {
config
.features
.disable(Feature::GhostCommit)
.expect("test config should allow feature update");
})
.build(&server)
.await
.unwrap();
codex
.submit(Op::UserInput {
environments: None,
items: vec![UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
})
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await;
logs_assert(|lines: &[&str]| {
let line = lines
.iter()
.find(|line| {
line.contains("codex.tool_result")
&& line.contains(&"tool_name=local_shell".to_string())
&& line.contains("output=LocalShellCall without call_id or id")
})
.ok_or_else(|| "missing codex.tool_result event".to_string())?;
if !line.contains("success=false") {
return Err("missing success field".to_string());
}
assert_empty_mcp_tool_fields(line)?;
Ok(())
});
}
#[cfg(target_os = "macos")]
#[tokio::test]
#[traced_test]
async fn handle_response_item_records_tool_result_for_local_shell_call() {
let server = start_mock_server().await;
mount_sse_once(
&server,
sse(vec![
ev_local_shell_call("shell-call", "completed", vec!["/bin/echo", "shell"]),
ev_completed("done"),
]),
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_assistant_message("msg-1", "shell command done"),
ev_completed("done"),
]),
)
@@ -1129,10 +1062,10 @@ async fn handle_response_item_records_tool_result_for_local_shell_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=local_shell") {
if !line.contains("tool_name=shell_command") {
return Err("missing tool_name field".to_string());
}
if !line.contains("arguments=/bin/echo shell") {
if !line.contains("arguments={\"command\":\"echo shell\"}") {
return Err("missing arguments field".to_string());
}
let output_idx = line
@@ -1168,8 +1101,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=local_shell") {
return Err("missing tool_name for local_shell".to_string());
if !lower.contains("tool_name=shell_command") {
return Err("missing tool_name for shell_command".to_string());
}
if !lower.contains(&format!("decision={expected_decision}")) {
return Err(format!("unexpected decision for {call_id}"));
@@ -1184,16 +1117,12 @@ fn tool_decision_assertion<'a>(
#[tokio::test]
#[traced_test]
async fn handle_container_exec_autoapprove_from_config_records_tool_decision() {
async fn handle_shell_command_autoapprove_from_config_records_tool_decision() {
let server = start_mock_server().await;
mount_sse_once(
&server,
sse(vec![
ev_local_shell_call(
"auto_config_call",
"completed",
vec!["/bin/echo", "local shell"],
),
shell_command_call("auto_config_call", "echo local shell"),
ev_completed("done"),
]),
)
@@ -1202,7 +1131,7 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() {
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_assistant_message("msg-1", "shell command done"),
ev_completed("done"),
]),
)
@@ -1244,16 +1173,13 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() {
#[tokio::test]
#[traced_test]
async fn handle_container_exec_user_approved_records_tool_decision() {
async fn handle_shell_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![
ev_local_shell_call(
"user_approved_call",
"completed",
vec!["/usr/bin/touch", "codex-otel-approval-test"],
),
shell_command_call("user_approved_call", &command),
ev_completed("done"),
]),
)
@@ -1262,7 +1188,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() {
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_assistant_message("msg-1", "shell command done"),
ev_completed("done"),
]),
)
@@ -1316,17 +1242,14 @@ async fn handle_container_exec_user_approved_records_tool_decision() {
#[tokio::test]
#[traced_test]
async fn handle_container_exec_user_approved_for_session_records_tool_decision() {
async fn handle_shell_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![
ev_local_shell_call(
"user_approved_session_call",
"completed",
vec!["/usr/bin/touch", "codex-otel-approval-test"],
),
shell_command_call("user_approved_session_call", &command),
ev_completed("done"),
]),
)
@@ -1334,7 +1257,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision()
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_assistant_message("msg-1", "shell command done"),
ev_completed("done"),
]),
)
@@ -1390,15 +1313,12 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision()
#[traced_test]
async fn handle_sandbox_error_user_approves_retry_records_tool_decision() {
let server = start_mock_server().await;
let command = touch_command("codex-otel-approval-test");
mount_sse_once(
&server,
sse(vec![
ev_local_shell_call(
"sandbox_retry_call",
"completed",
vec!["/usr/bin/touch", "codex-otel-approval-test"],
),
shell_command_call("sandbox_retry_call", &command),
ev_completed("done"),
]),
)
@@ -1406,7 +1326,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() {
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_assistant_message("msg-1", "shell command done"),
ev_completed("done"),
]),
)
@@ -1460,17 +1380,14 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() {
#[tokio::test]
#[traced_test]
async fn handle_container_exec_user_denies_records_tool_decision() {
async fn handle_shell_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![
ev_local_shell_call(
"user_denied_call",
"completed",
vec!["/usr/bin/touch", "codex-otel-approval-test"],
),
shell_command_call("user_denied_call", &command),
ev_completed("done"),
]),
)
@@ -1479,7 +1396,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() {
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_assistant_message("msg-1", "shell command done"),
ev_completed("done"),
]),
)
@@ -1534,15 +1451,12 @@ async fn handle_container_exec_user_denies_records_tool_decision() {
#[traced_test]
async fn handle_sandbox_error_user_approves_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![
ev_local_shell_call(
"sandbox_session_call",
"completed",
vec!["/usr/bin/touch", "codex-otel-approval-test"],
),
shell_command_call("sandbox_session_call", &command),
ev_completed("done"),
]),
)
@@ -1550,7 +1464,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision()
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_assistant_message("msg-1", "shell command done"),
ev_completed("done"),
]),
)
@@ -1606,15 +1520,12 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision()
#[traced_test]
async fn handle_sandbox_error_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![
ev_local_shell_call(
"sandbox_deny_call",
"completed",
vec!["/usr/bin/touch", "codex-otel-approval-test"],
),
shell_command_call("sandbox_deny_call", &command),
ev_completed("done"),
]),
)
@@ -1623,7 +1534,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() {
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_assistant_message("msg-1", "shell command done"),
ev_completed("done"),
]),
)
@@ -7,7 +7,6 @@ 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_local_shell_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
@@ -67,34 +66,6 @@ fn shell_responses(
]),
])
}
ShellModelOutput::Shell => {
let parameters = json!({
"command": command,
"timeout_ms": 2_000,
});
Ok(vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell", &serde_json::to_string(&parameters)?),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
])
}
ShellModelOutput::LocalShell => Ok(vec![
sse(vec![
ev_response_created("resp-1"),
ev_local_shell_call(call_id, "completed", command),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
]),
}
}
@@ -103,12 +74,8 @@ fn configure_shell_model(
output_type: ShellModelOutput,
include_apply_patch_tool: bool,
) -> TestCodexBuilder {
let builder = match (output_type, include_apply_patch_tool) {
(ShellModelOutput::ShellCommand, _) => builder.with_model("test-gpt-5-codex"),
(ShellModelOutput::LocalShell, true) => builder.with_model("gpt-5.4"),
(ShellModelOutput::Shell, true) => builder.with_model("gpt-5.4"),
(ShellModelOutput::LocalShell, false) => builder.with_model("test-local-shell-json"),
(ShellModelOutput::Shell, false) => builder.with_model("test-shell-json"),
let builder = match output_type {
ShellModelOutput::ShellCommand => builder.with_model("test-gpt-5-codex"),
};
builder.with_config(move |config| {
@@ -117,64 +84,7 @@ fn configure_shell_model(
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ShellModelOutput::Shell)]
#[test_case(ShellModelOutput::LocalShell)]
async fn shell_output_stays_json_without_freeform_apply_patch(
output_type: ShellModelOutput,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = configure_shell_model(
test_codex(),
output_type,
/*include_apply_patch_tool*/ false,
);
let test = builder.build(&server).await?;
let call_id = "shell-json";
let responses = shell_responses(call_id, vec!["/bin/echo", "shell json"], output_type)?;
let mock = mount_sse_sequence(&server, responses).await;
test.submit_turn_with_permission_profile(
"run the json 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 mut parsed: Value = serde_json::from_str(output)?;
if let Some(metadata) = parsed.get_mut("metadata").and_then(Value::as_object_mut) {
let _ = metadata.remove("duration_seconds");
}
assert_eq!(
parsed
.get("metadata")
.and_then(|metadata| metadata.get("exit_code"))
.and_then(Value::as_i64),
Some(0),
"expected zero exit code in unformatted JSON output",
);
let stdout = parsed
.get("output")
.and_then(Value::as_str)
.unwrap_or_default();
assert_regex_match(r"(?s)^shell json\n?$", stdout);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ShellModelOutput::Shell)]
#[test_case(ShellModelOutput::ShellCommand)]
#[test_case(ShellModelOutput::LocalShell)]
async fn shell_output_is_structured_with_freeform_apply_patch(
output_type: ShellModelOutput,
) -> Result<()> {
@@ -222,76 +132,7 @@ freeform shell
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ShellModelOutput::Shell)]
#[test_case(ShellModelOutput::LocalShell)]
async fn shell_output_preserves_fixture_json_without_serialization(
output_type: ShellModelOutput,
) -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = configure_shell_model(
test_codex(),
output_type,
/*include_apply_patch_tool*/ false,
);
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-json-fixture";
let responses = shell_responses(
call_id,
vec!["/usr/bin/sed", "-n", "p", fixture_path_str.as_str()],
output_type,
)?;
let mock = mount_sse_sequence(&server, responses).await;
test.submit_turn_with_permission_profile(
"read the fixture JSON with sed",
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 mut parsed: Value = serde_json::from_str(output)?;
if let Some(metadata) = parsed.get_mut("metadata").and_then(Value::as_object_mut) {
let _ = metadata.remove("duration_seconds");
}
assert_eq!(
parsed
.get("metadata")
.and_then(|metadata| metadata.get("exit_code"))
.and_then(Value::as_i64),
Some(0),
"expected zero exit code when serialization is disabled",
);
let stdout = parsed
.get("output")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
assert_eq!(
stdout, FIXTURE_JSON,
"expected shell output to match the fixture contents"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ShellModelOutput::Shell)]
#[test_case(ShellModelOutput::ShellCommand)]
#[test_case(ShellModelOutput::LocalShell)]
async fn shell_output_structures_fixture_with_serialization(
output_type: ShellModelOutput,
) -> Result<()> {
@@ -352,9 +193,7 @@ async fn shell_output_structures_fixture_with_serialization(
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ShellModelOutput::Shell)]
#[test_case(ShellModelOutput::ShellCommand)]
#[test_case(ShellModelOutput::LocalShell)]
async fn shell_output_for_freeform_tool_records_duration(
output_type: ShellModelOutput,
) -> Result<()> {
@@ -408,72 +247,8 @@ $"#;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ShellModelOutput::Shell)]
#[test_case(ShellModelOutput::LocalShell)]
async fn shell_output_reserializes_truncated_content(output_type: ShellModelOutput) -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = configure_shell_model(
test_codex(),
output_type,
/*include_apply_patch_tool*/ true,
)
.with_config(move |config| {
config.tool_output_token_limit = Some(200);
});
let test = builder.build(&server).await?;
let call_id = "shell-truncated";
let responses = shell_responses(call_id, vec!["/bin/sh", "-c", "seq 1 400"], output_type)?;
let mock = mount_sse_sequence(&server, responses).await;
test.submit_turn_with_permission_profile(
"run the truncation shell command",
PermissionProfile::Disabled,
)
.await?;
let req = mock
.last_request()
.expect("truncated output request recorded");
let output_item = req.function_call_output(call_id);
let output = output_item
.get("output")
.and_then(Value::as_str)
.expect("truncated output string");
assert!(
serde_json::from_str::<Value>(output).is_err(),
"expected truncated shell output to be plain text",
);
let truncated_pattern = r#"(?s)^Exit code: 0
Wall time: [0-9]+(?:\.[0-9]+)? seconds
Total output lines: 400
Output:
1
2
3
4
5
6
.*46 tokens truncated.*
396
397
398
399
400
$"#;
assert_regex_match(truncated_pattern, output);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Shell)]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
async fn apply_patch_custom_tool_output_is_structured(
output_type: ApplyPatchModelOutput,
) -> Result<()> {
@@ -517,8 +292,6 @@ A {file_name}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Shell)]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
async fn apply_patch_custom_tool_call_creates_file(
output_type: ApplyPatchModelOutput,
) -> Result<()> {
@@ -564,8 +337,6 @@ A {file_name}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Shell)]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
async fn apply_patch_custom_tool_call_updates_existing_file(
output_type: ApplyPatchModelOutput,
) -> Result<()> {
@@ -616,8 +387,6 @@ M {file_name}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Shell)]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
async fn apply_patch_custom_tool_call_reports_failure_output(
output_type: ApplyPatchModelOutput,
) -> Result<()> {
@@ -660,8 +429,6 @@ async fn apply_patch_custom_tool_call_reports_failure_output(
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Shell)]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
async fn apply_patch_tool_output_is_structured(output_type: ApplyPatchModelOutput) -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -702,9 +469,7 @@ A {file_name}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(ShellModelOutput::Shell)]
#[test_case(ShellModelOutput::ShellCommand)]
#[test_case(ShellModelOutput::LocalShell)]
async fn shell_output_is_structured_for_nonzero_exit(output_type: ShellModelOutput) -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -897,52 +662,3 @@ Output:
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn local_shell_call_output_is_structured() -> 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.include_apply_patch_tool = true;
});
let test = builder.build(&server).await?;
let call_id = "local-shell-call";
let responses = vec![
sse(vec![
json!({"type": "response.created", "response": {"id": "resp-1"}}),
ev_local_shell_call(call_id, "completed", vec!["/bin/echo", "local shell"]),
ev_completed("resp-1"),
]),
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("resp-2"),
]),
];
let mock = mount_sse_sequence(&server, responses).await;
test.submit_turn_with_permission_profile(
"run the local shell command",
PermissionProfile::Disabled,
)
.await?;
let req = mock
.last_request()
.expect("local 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("local shell output string");
let expected_pattern = r"(?s)^Exit code: 0
Wall time: [0-9]+(?:\.[0-9]+)? seconds
Output:
local shell
?$";
assert_regex_match(expected_pattern, output);
Ok(())
}
@@ -16,5 +16,5 @@ Scenario: /responses POST bodies (input only, redacted like other suite snapshot
03:reasoning:summary=thinking:encrypted=true
04:function_call/shell
05:message/assistant:first answer
06:function_call_output:failed to parse function arguments: invalid type: string "echo preserved tool call", expected a sequence at line 1 column 37
06:function_call_output:unsupported call: shell
07:message/user:second prompt
+9 -6
View File
@@ -18,7 +18,6 @@ use core_test_support::responses::ev_apply_patch_custom_tool_call;
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_local_shell_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
@@ -66,12 +65,12 @@ fn custom_call_output(req: &ResponsesRequest, call_id: &str) -> (String, Option<
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> {
async fn shell_command_tool_executes_command_and_streams_output() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex().with_model("test-local-shell-json");
let mut builder = test_codex().with_model("test-gpt-5-codex");
let TestCodex {
codex,
cwd,
@@ -79,11 +78,15 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()>
..
} = builder.build(&server).await?;
let call_id = "shell-tool-call";
let command = vec!["/bin/echo", "tool harness"];
let call_id = "shell-command-tool-call";
let command_args = json!({
"command": "echo tool harness",
"login": false,
})
.to_string();
let first_response = sse(vec![
ev_response_created("resp-1"),
ev_local_shell_call(call_id, "completed", command),
ev_function_call(call_id, "shell_command", &command_args),
ev_completed("resp-1"),
]);
responses::mount_sse_once(&server, first_response).await;
+40 -120
View File
@@ -182,24 +182,26 @@ async fn custom_tool_unknown_returns_custom_output_error() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> {
async fn shell_command_escalated_permissions_rejected_then_ok() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex().with_model("test-shell-json");
let mut builder = test_codex().with_model("test-gpt-5-codex");
let test = builder.build(&server).await?;
let command = ["/bin/echo", "shell ok"];
let call_id_blocked = "shell-blocked";
let call_id_success = "shell-success";
let command = "echo shell ok";
let call_id_blocked = "shell-command-blocked";
let call_id_success = "shell-command-success";
let first_args = json!({
"command": command,
"login": false,
"timeout_ms": 1_000,
"sandbox_permissions": SandboxPermissions::RequireEscalated,
});
let second_args = json!({
"command": command,
"login": false,
"timeout_ms": 1_000,
});
@@ -209,7 +211,7 @@ async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> {
ev_response_created("resp-1"),
ev_function_call(
call_id_blocked,
"shell",
"shell_command",
&serde_json::to_string(&first_args)?,
),
ev_completed("resp-1"),
@@ -222,7 +224,7 @@ async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> {
ev_response_created("resp-2"),
ev_function_call(
call_id_success,
"shell",
"shell_command",
&serde_json::to_string(&second_args)?,
),
ev_completed("resp-2"),
@@ -239,7 +241,7 @@ async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> {
.await;
test.submit_turn_with_approval_and_permission_profile(
"run the shell command",
"run the shell_command script",
AskForApproval::Never,
PermissionProfile::Disabled,
)
@@ -274,35 +276,32 @@ async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn sandbox_denied_shell_returns_original_output() -> Result<()> {
async fn sandbox_denied_shell_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";
let call_id = "sandbox-denied-shell-command";
let target_path = fixture.workspace_path("sandbox-denied.txt");
let sentinel = "sandbox-denied sentinel output";
let command = vec![
"/bin/sh".to_string(),
"-c".to_string(),
format!(
"printf {sentinel:?}; printf {content:?} > {path:?}",
sentinel = format!("{sentinel}\n"),
content = "sandbox denied",
path = &target_path
),
];
let command = format!(
"printf {sentinel:?}; printf {content:?} > {path:?}",
sentinel = format!("{sentinel}\n"),
content = "sandbox denied",
path = &target_path
);
let args = json!({
"command": command,
"login": false,
"timeout_ms": 5_000,
});
let responses = vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell", &serde_json::to_string(&args)?),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
sse(vec![
@@ -367,7 +366,7 @@ async fn sandbox_denied_shell_returns_original_output() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_enforces_glob_deny_read_policy() -> Result<()> {
async fn shell_command_enforces_glob_deny_read_policy() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
@@ -403,24 +402,22 @@ async fn shell_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-glob-deny-read";
let command = vec![
"/bin/sh".to_string(),
"-c".to_string(),
"status=0; cat \"$1\" || status=$?; cat \"$2\"; exit \"$status\"".to_string(),
"sh".to_string(),
denied_path.to_string_lossy().into_owned(),
allowed_path.to_string_lossy().into_owned(),
];
let call_id = "shell-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,
"login": false,
"timeout_ms": 1_000,
});
let responses = vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell", &serde_json::to_string(&args)?),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
sse(vec![
@@ -537,17 +534,18 @@ async fn unified_exec_spec_toggle_end_to_end() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_timeout_includes_timeout_prefix_and_metadata() -> Result<()> {
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-shell-json");
let mut builder = test_codex().with_model("test-gpt-5-codex");
let test = builder.build(&server).await?;
let call_id = "shell-timeout";
let call_id = "shell-command-timeout";
let timeout_ms = 50u64;
let args = json!({
"command": ["/bin/sh", "-c", "yes line | head -n 400; sleep 1"],
"command": "yes line | head -n 400; sleep 1",
"login": false,
"timeout_ms": timeout_ms,
});
@@ -555,7 +553,7 @@ async fn shell_timeout_includes_timeout_prefix_and_metadata() -> Result<()> {
&server,
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell", &serde_json::to_string(&args)?),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
)
@@ -622,7 +620,7 @@ async fn shell_timeout_includes_timeout_prefix_and_metadata() -> Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_timeout_handles_background_grandchild_stdout() -> Result<()> {
async fn shell_command_timeout_handles_background_grandchild_stdout() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
@@ -634,7 +632,7 @@ async fn shell_timeout_handles_background_grandchild_stdout() -> Result<()> {
});
let test = builder.build(&server).await?;
let call_id = "shell-grandchild-timeout";
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!(
@@ -651,7 +649,8 @@ time.sleep(60)
fs::write(&script_path, script)?;
let args = json!({
"command": ["python3", script_path.to_string_lossy()],
"command": format!("python3 {:?}", script_path.to_string_lossy()),
"login": false,
"timeout_ms": 200,
});
@@ -659,7 +658,7 @@ time.sleep(60)
&server,
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell", &serde_json::to_string(&args)?),
ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?),
ev_completed("resp-1"),
]),
)
@@ -716,82 +715,3 @@ time.sleep(60)
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_spawn_failure_truncates_exec_error() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let mut builder = test_codex().with_config(|cfg| {
cfg.permissions
.set_permission_profile(PermissionProfile::Disabled)
.expect("set permission profile");
});
let test = builder.build(&server).await?;
let call_id = "shell-spawn-failure";
let bogus_component = "missing-bin-".repeat(700);
let bogus_exe = test
.cwd
.path()
.join(bogus_component)
.to_string_lossy()
.into_owned();
let args = json!({
"command": [bogus_exe],
"timeout_ms": 1_000,
});
mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "shell", &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(
"spawn a missing binary",
AskForApproval::Never,
PermissionProfile::Disabled,
)
.await?;
let failure_item = second_mock.single_request().function_call_output(call_id);
let output = failure_item
.get("output")
.and_then(Value::as_str)
.expect("spawn failure output string");
let spawn_error_pattern = r#"(?s)^Exit code: -?\d+
Wall time: [0-9]+(?:\.[0-9]+)? seconds
Output:
execution error: .*$"#;
let spawn_truncated_pattern = r#"(?s)^Exit code: -?\d+
Wall time: [0-9]+(?:\.[0-9]+)? seconds
Total output lines: \d+
Output:
execution error: .*$"#;
let spawn_error_regex = Regex::new(spawn_error_pattern)?;
let spawn_truncated_regex = Regex::new(spawn_truncated_pattern)?;
if !spawn_error_regex.is_match(output) && !spawn_truncated_regex.is_match(output) {
let fallback_pattern = r"(?s)^execution error: .*$";
assert_regex_match(fallback_pattern, output);
}
assert!(output.len() <= 10 * 1024);
Ok(())
}