mirror of
https://github.com/openai/codex.git
synced 2026-11-05 08:28:29 +00:00
Support configurable timeouts for thread shell commands (#41384)
## What changed - Add an optional `timeoutMs` parameter to `thread/shellCommand` and propagate it to user shell execution. - Preserve the one-hour default when the parameter is omitted or `null`, allow longer deadlines, and treat `0` as an immediate timeout. - Reject negative or otherwise invalid values before execution. Timing out an auxiliary shell command leaves its active turn running. ## Testing - Cover default, extended, immediate, invalid, standalone, active-turn, and interruption behavior. GitOrigin-RevId: d72f67793d9766b7383f28d30140270a4a76a495
This commit is contained in:
1 parent
4ee04c0aa5
commit
e4d0ba4e92
20 files changed
+475
-45
No files matched your search
@@ -385,6 +385,7 @@ async fn shell_snapshot_v2_preserves_legacy_snapshots_for_user_shell() -> Result
|
||||
codex
|
||||
.submit(Op::RunUserShellCommand {
|
||||
command: "printf legacy".to_string(),
|
||||
timeout_ms: None,
|
||||
})
|
||||
.await?;
|
||||
let end = wait_for_event_match(codex, |event| match event {
|
||||
|
||||
@@ -14,8 +14,10 @@ use codex_protocol::protocol::ExecOutputStream;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::ThreadSettingsOverrides;
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::TurnEnvironmentSelections;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::PathBufExt;
|
||||
use core_test_support::PathExt;
|
||||
use core_test_support::assert_regex_match;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
@@ -27,6 +29,7 @@ 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::submit_thread_settings;
|
||||
use core_test_support::test_codex::local;
|
||||
use core_test_support::test_codex::local_selections;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::test_codex::turn_permission_fields;
|
||||
@@ -66,7 +69,10 @@ async fn user_shell_cmd_ls_and_cat_in_temp_dir() {
|
||||
// 1) shell command should list the file
|
||||
let list_cmd = "ls".to_string();
|
||||
codex
|
||||
.submit(Op::RunUserShellCommand { command: list_cmd })
|
||||
.submit(Op::RunUserShellCommand {
|
||||
command: list_cmd,
|
||||
timeout_ms: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecCommandEnd(_))).await;
|
||||
@@ -85,7 +91,10 @@ async fn user_shell_cmd_ls_and_cat_in_temp_dir() {
|
||||
// 2) shell command should print the file contents verbatim
|
||||
let cat_cmd = format!("cat {file_name}");
|
||||
codex
|
||||
.submit(Op::RunUserShellCommand { command: cat_cmd })
|
||||
.submit(Op::RunUserShellCommand {
|
||||
command: cat_cmd,
|
||||
timeout_ms: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecCommandEnd(_))).await;
|
||||
@@ -125,6 +134,7 @@ async fn user_shell_command_without_local_environment_emits_error() -> anyhow::R
|
||||
test.codex
|
||||
.submit(Op::RunUserShellCommand {
|
||||
command: "echo shell".to_string(),
|
||||
timeout_ms: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -150,19 +160,19 @@ async fn user_shell_cmd_can_be_interrupted() {
|
||||
.expect("create new conversation");
|
||||
let codex = &fixture.codex;
|
||||
|
||||
// Start a long-running command and then interrupt it.
|
||||
let sleep_cmd = "sleep 5".to_string();
|
||||
// Start a long-running command and then interrupt it before its deadline.
|
||||
codex
|
||||
.submit(Op::RunUserShellCommand { command: sleep_cmd })
|
||||
.submit(Op::RunUserShellCommand {
|
||||
command: slow_user_shell_command().to_string(),
|
||||
timeout_ms: Some(28_800_000),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Wait until it has started (ExecCommandBegin), then interrupt.
|
||||
let _begin = wait_for_event_match(codex, |ev| match ev {
|
||||
EventMsg::ExecCommandBegin(event) if event.source == ExecCommandSource::UserShell => {
|
||||
Some(event.clone())
|
||||
}
|
||||
_ => None,
|
||||
// Output proves that the process was spawned before cancellation.
|
||||
wait_for_event(codex, |event| {
|
||||
matches!(event, EventMsg::ExecCommandOutputDelta(delta)
|
||||
if String::from_utf8_lossy(&delta.chunk).contains("shell-timeout-ready"))
|
||||
})
|
||||
.await;
|
||||
codex.submit(Op::Interrupt).await.unwrap();
|
||||
@@ -180,6 +190,87 @@ async fn user_shell_cmd_can_be_interrupted() {
|
||||
assert_eq!(ev.reason, TurnAbortReason::Interrupted);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_shell_command_honors_default_and_extended_deadlines() -> anyhow::Result<()> {
|
||||
for (timeout_ms, expected_ms) in [(None, 3_600_000), (Some(28_800_000), 28_800_000)] {
|
||||
let server = start_mock_server().await;
|
||||
// Honor the CI executor while retaining the local environment used by user shell commands.
|
||||
let mut builder = test_codex();
|
||||
let fixture = builder.build_with_remote_and_local_env(&server).await?;
|
||||
// The remote cwd need not exist on the host, and may use a different path convention.
|
||||
let local_cwd = fixture.cwd_path().abs();
|
||||
let mut environments = vec![local(local_cwd.clone())];
|
||||
if fixture.executor_environment().environment().is_remote() {
|
||||
environments.insert(
|
||||
/*index*/ 0,
|
||||
fixture.executor_environment().selection().clone(),
|
||||
);
|
||||
}
|
||||
submit_thread_settings(
|
||||
&fixture.codex,
|
||||
ThreadSettingsOverrides {
|
||||
environments: Some(TurnEnvironmentSelections::new(local_cwd, environments)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
fixture
|
||||
.codex
|
||||
.submit(Op::RunUserShellCommand {
|
||||
command: slow_user_shell_command().to_string(),
|
||||
timeout_ms,
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&fixture.codex, |event| {
|
||||
matches!(event, EventMsg::ExecCommandOutputDelta(delta)
|
||||
if String::from_utf8_lossy(&delta.chunk).contains("shell-timeout-ready"))
|
||||
})
|
||||
.await;
|
||||
|
||||
let completed = async {
|
||||
loop {
|
||||
let event = fixture.codex.next_event().await.expect("read shell event");
|
||||
if let EventMsg::ExecCommandEnd(end) = event.msg {
|
||||
break end;
|
||||
}
|
||||
}
|
||||
};
|
||||
tokio::pin!(completed);
|
||||
|
||||
// Leave time for shell startup, and let process I/O and reaping use real time.
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(Duration::from_millis(expected_ms - 60_000)).await;
|
||||
tokio::time::resume();
|
||||
assert!(
|
||||
timeout(Duration::from_millis(250), &mut completed)
|
||||
.await
|
||||
.is_err(),
|
||||
"shell command expired before its {expected_ms} ms deadline"
|
||||
);
|
||||
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(Duration::from_secs(61)).await;
|
||||
tokio::time::resume();
|
||||
let end = timeout(Duration::from_secs(10), &mut completed).await?;
|
||||
assert_eq!(end.exit_code, -1);
|
||||
assert!(end.aggregated_output.contains("Timeout"));
|
||||
assert!(end.aggregated_output.contains("shell-timeout-ready"));
|
||||
wait_for_event(&fixture.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn slow_user_shell_command() -> &'static str {
|
||||
match codex_core::shell::default_user_shell().name() {
|
||||
"powershell" => "Write-Output shell-timeout-ready; Start-Sleep -Seconds 60",
|
||||
"cmd" => "echo shell-timeout-ready & ping -n 61 127.0.0.1 > nul",
|
||||
_ => "printf 'shell-timeout-ready\\n'; exec sleep 60",
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
@@ -256,6 +347,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()>
|
||||
.codex
|
||||
.submit(Op::RunUserShellCommand {
|
||||
command: user_shell_command,
|
||||
timeout_ms: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -321,6 +413,7 @@ async fn user_shell_command_history_is_persisted_and_shared_with_model() -> anyh
|
||||
test.codex
|
||||
.submit(Op::RunUserShellCommand {
|
||||
command: command.clone(),
|
||||
timeout_ms: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -406,7 +499,10 @@ async fn user_shell_command_does_not_set_network_sandbox_env_var() -> anyhow::Re
|
||||
r#"sh -c "printf '%s' \"${CODEX_SANDBOX_NETWORK_DISABLED:-not-set}\"""#.to_string();
|
||||
|
||||
test.codex
|
||||
.submit(Op::RunUserShellCommand { command })
|
||||
.submit(Op::RunUserShellCommand {
|
||||
command,
|
||||
timeout_ms: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let ExecCommandEndEvent {
|
||||
@@ -449,6 +545,7 @@ async fn user_shell_command_output_is_truncated_in_history() -> anyhow::Result<(
|
||||
test.codex
|
||||
.submit(Op::RunUserShellCommand {
|
||||
command: command.clone(),
|
||||
timeout_ms: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
Reference in new issue
Block a user