mirror of
https://github.com/openai/codex.git
synced 2026-10-16 16:58:46 +00:00
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:
Generated
-1
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
]))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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,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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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, ¶ms.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(),
|
||||
¶ms.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(
|
||||
¶ms,
|
||||
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(
|
||||
¶ms,
|
||||
&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(
|
||||
¶ms,
|
||||
&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(
|
||||
¶ms,
|
||||
&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"),
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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()]),
|
||||
)?;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")]),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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(¶meters)?,
|
||||
),
|
||||
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(())
|
||||
}
|
||||
@@ -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?;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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"),
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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(_))
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user