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:
gero-oai
2026-08-28 18:07:40 +00:00
committed by copyberry
parent 4ee04c0aa5
commit e4d0ba4e92
20 changed files with 475 additions and 45 deletions
@@ -4988,6 +4988,14 @@
},
"threadId": {
"type": "string"
},
"timeoutMs": {
"description": "Maximum execution time in milliseconds. Defaults to one hour when omitted or null. Must be non-negative; zero requests an immediate timeout, not unlimited execution. Does not affect the immediate RPC acknowledgement.",
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"required": [
@@ -23184,6 +23184,14 @@
},
"threadId": {
"type": "string"
},
"timeoutMs": {
"description": "Maximum execution time in milliseconds. Defaults to one hour when omitted or null. Must be non-negative; zero requests an immediate timeout, not unlimited execution. Does not affect the immediate RPC acknowledgement.",
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"required": [
@@ -20919,6 +20919,14 @@
},
"threadId": {
"type": "string"
},
"timeoutMs": {
"description": "Maximum execution time in milliseconds. Defaults to one hour when omitted or null. Must be non-negative; zero requests an immediate timeout, not unlimited execution. Does not affect the immediate RPC acknowledgement.",
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"required": [
@@ -7,6 +7,14 @@
},
"threadId": {
"type": "string"
},
"timeoutMs": {
"description": "Maximum execution time in milliseconds. Defaults to one hour when omitted or null. Must be non-negative; zero requests an immediate timeout, not unlimited execution. Does not affect the immediate RPC acknowledgement.",
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"required": [
@@ -9,4 +9,10 @@ export type ThreadShellCommandParams = { threadId: string,
* such as pipes, redirects, and quoting. This runs unsandboxed with full
* access rather than inheriting the thread sandbox policy.
*/
command: string, };
command: string,
/**
* Maximum execution time in milliseconds. Defaults to one hour when omitted
* or null. Must be non-negative; zero requests an immediate timeout, not
* unlimited execution. Does not affect the immediate RPC acknowledgement.
*/
timeoutMs?: number | null, };
@@ -1306,23 +1306,44 @@ fn fs_copy_params_round_trip_with_recursive_directory_copy() {
#[test]
fn thread_shell_command_params_round_trip() {
let params = ThreadShellCommandParams {
thread_id: "thr_123".to_string(),
command: "printf 'hello world\\n'".to_string(),
};
for timeout_ms in [None, Some(0), Some(28_800_000)] {
let params = ThreadShellCommandParams {
thread_id: "thr_123".to_string(),
command: "printf 'hello world\\n'".to_string(),
timeout_ms,
};
let value = serde_json::to_value(&params).expect("serialize thread/shellCommand params");
let value = serde_json::to_value(&params).expect("serialize thread/shellCommand params");
assert_eq!(
value,
json!({
"threadId": "thr_123",
"command": "printf 'hello world\\n'",
"timeoutMs": timeout_ms,
})
);
let decoded = serde_json::from_value::<ThreadShellCommandParams>(value)
.expect("deserialize thread/shellCommand params");
assert_eq!(decoded, params);
}
}
#[test]
fn thread_shell_command_params_without_timeout_remain_valid() {
let decoded = serde_json::from_value::<ThreadShellCommandParams>(json!({
"threadId": "thr_123",
"command": "echo hello",
}))
.expect("deserialize thread/shellCommand params without timeoutMs");
assert_eq!(
value,
json!({
"threadId": "thr_123",
"command": "printf 'hello world\\n'",
})
decoded,
ThreadShellCommandParams {
thread_id: "thr_123".to_string(),
command: "echo hello".to_string(),
timeout_ms: None,
}
);
let decoded = serde_json::from_value::<ThreadShellCommandParams>(value)
.expect("deserialize thread/shellCommand params");
assert_eq!(decoded, params);
}
#[test]
@@ -1129,6 +1129,12 @@ pub struct ThreadShellCommandParams {
/// such as pipes, redirects, and quoting. This runs unsandboxed with full
/// access rather than inheriting the thread sandbox policy.
pub command: String,
/// Maximum execution time in milliseconds. Defaults to one hour when omitted
/// or null. Must be non-negative; zero requests an immediate timeout, not
/// unlimited execution. Does not affect the immediate RPC acknowledgement.
#[ts(type = "number | null")]
#[ts(optional = nullable)]
pub timeout_ms: Option<i64>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
+15 -1
View File
@@ -923,7 +923,14 @@ Use `thread/shellCommand` for the TUI `!` workflow. The request returns immediat
This API runs unsandboxed with full access; it does not inherit the thread
sandbox policy.
If the thread already has an active turn, the command runs as an auxiliary action on that turn. In that case, progress is emitted as standard `item/*` notifications on the existing turn and the formatted output is injected into the turn’s message stream:
Set `timeoutMs` to a non-negative integer to control command execution time.
Omitting it or setting it to `null` preserves the one-hour default (3,600,000 ms).
Values above one hour are supported; `0` requests an immediate timeout, not
unlimited execution. Invalid values are rejected before execution. This deadline
does not change the immediate RPC acknowledgement, and `turn/interrupt` can still
cancel execution before the deadline.
If the thread already has an active turn, the command runs as an auxiliary action on that turn. A timeout ends only the shell command, not the active turn. Progress is emitted as standard `item/*` notifications on the existing turn and the formatted output is injected into the turn’s message stream:
- `item/started` with `item: { "type": "commandExecution", "source": "userShell", ... }`
- zero or more `item/commandExecution/outputDelta`
@@ -942,6 +949,13 @@ If the thread does not already have an active turn, the server starts a standalo
{ "id": 26, "result": {} }
```
For example, allow up to eight hours for a workflow command:
```json
{ "method": "thread/shellCommand", "id": 27, "params": { "threadId": "thr_b", "command": "./workflow.sh", "timeoutMs": 28800000 } }
{ "id": 27, "result": {} }
```
### Example: Start a turn (send user input)
Turns attach user input (text, images, or audio) to a thread and trigger Codex generation. The `input` field is a list of discriminated unions:
@@ -2426,12 +2426,26 @@ impl ThreadRequestProcessor {
request_id: &ConnectionRequestId,
params: ThreadShellCommandParams,
) -> Result<ThreadShellCommandResponse, JSONRPCErrorError> {
let ThreadShellCommandParams { thread_id, command } = params;
let ThreadShellCommandParams {
thread_id,
command,
timeout_ms,
} = params;
let command = command.trim().to_string();
if command.is_empty() {
return Err(invalid_request("command must not be empty"));
}
let timeout_ms = timeout_ms
.map(|timeout_ms| {
u64::try_from(timeout_ms).map_err(|_| {
invalid_params(format!(
"thread/shellCommand timeoutMs must be non-negative, got {timeout_ms}"
))
})
})
.transpose()?;
let (_, thread) = self.load_thread(&thread_id).await?;
ensure_direct_input_allowed(thread.as_ref()).await?;
@@ -2449,7 +2463,10 @@ impl ThreadRequestProcessor {
self.submit_core_op(
request_id,
thread.as_ref(),
Op::RunUserShellCommand { command },
Op::RunUserShellCommand {
command,
timeout_ms,
},
)
.await
.map_err(|err| internal_error(format!("failed to start shell command: {err}")))?;
@@ -5,6 +5,7 @@ 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;
use codex_app_server::INVALID_PARAMS_ERROR_CODE;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::CommandExecutionApprovalDecision;
use codex_app_server_protocol::CommandExecutionOutputDeltaNotification;
@@ -26,15 +27,19 @@ use codex_app_server_protocol::ThreadShellCommandParams;
use codex_app_server_protocol::ThreadShellCommandResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::ThreadTurnsListParams;
use codex_app_server_protocol::ThreadTurnsListResponse;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStartedNotification;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_core::shell::default_user_shell;
use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;
@@ -75,6 +80,7 @@ async fn thread_shell_command_history_responses_exclude_persisted_command_execut
params: ThreadShellCommandParams {
thread_id: thread.id.clone(),
command: shell_command,
timeout_ms: None,
},
})
.await?;
@@ -187,6 +193,7 @@ async fn thread_shell_command_returns_error_when_local_environment_is_disabled()
.send_thread_shell_command_request(ThreadShellCommandParams {
thread_id: thread.id,
command: "pwd".to_string(),
timeout_ms: None,
})
.await?;
let error = mcp
@@ -199,6 +206,15 @@ async fn thread_shell_command_returns_error_when_local_environment_is_disabled()
#[tokio::test]
async fn thread_shell_command_uses_existing_active_turn() -> Result<()> {
check_thread_shell_command_in_active_turn(/*timeout_ms*/ None).await
}
#[tokio::test]
async fn thread_shell_command_timeout_preserves_active_turn() -> Result<()> {
check_thread_shell_command_in_active_turn(Some(3_000)).await
}
async fn check_thread_shell_command_in_active_turn(timeout_ms: Option<i64>) -> Result<()> {
let tmp = TempDir::new()?;
let codex_home = tmp.path().join("codex_home");
std::fs::create_dir(&codex_home)?;
@@ -238,7 +254,11 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> {
},
})
.await?;
let (shell_command, expected_output) = current_shell_output_command("active turn bang")?;
let (shell_command, expected_output) = if timeout_ms.is_some() {
current_shell_slow_command()?
} else {
current_shell_output_command("active turn bang")?
};
let TurnStartResponse { turn } = mcp
.request(|request_id| ClientRequest::TurnStart {
@@ -256,6 +276,8 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> {
})
.await?;
let started: TurnStartedNotification = mcp.read_notification("turn/started").await?;
assert_eq!(started.turn.id, turn.id);
let agent_started = wait_for_command_execution_started(&mut mcp, Some("call-approve")).await?;
let ThreadItem::CommandExecution {
command, source, ..
@@ -284,6 +306,7 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> {
params: ThreadShellCommandParams {
thread_id: thread.id.clone(),
command: shell_command,
timeout_ms,
},
})
.await?;
@@ -301,13 +324,46 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> {
let ThreadItem::CommandExecution {
source,
aggregated_output,
status,
exit_code,
..
} = &completed.item
else {
unreachable!("helper returns command execution item");
};
assert_eq!(source, &CommandExecutionSource::UserShell);
assert_eq!(aggregated_output.as_deref(), Some(expected_output.as_str()));
if timeout_ms.is_some() {
assert_eq!(status, &CommandExecutionStatus::Failed);
assert_eq!(*exit_code, Some(-1));
let output = aggregated_output.as_deref().expect("timeout output");
assert!(output.contains("Timeout"));
assert!(output.contains(expected_output.trim()));
} else {
assert_eq!(status, &CommandExecutionStatus::Completed);
assert_eq!(*exit_code, Some(0));
assert_eq!(aggregated_output.as_deref(), Some(expected_output.as_str()));
}
let ThreadReadResponse {
thread: active_thread,
..
} = mcp
.request(|request_id| ClientRequest::ThreadRead {
request_id,
params: ThreadReadParams {
thread_id: thread.id.clone(),
include_turns: true,
},
})
.await?;
assert_eq!(active_thread.turns.len(), 1);
assert_eq!(active_thread.turns[0].id, turn.id);
assert_eq!(active_thread.turns[0].status, TurnStatus::InProgress);
assert!(
mcp.pending_notification_methods()
.iter()
.all(|method| { method != "turn/started" && method != "turn/completed" })
);
mcp.send_response(
request_id,
@@ -316,11 +372,13 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> {
})?,
)
.await?;
let _: TurnCompletedNotification = timeout(
let completed: TurnCompletedNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_notification("turn/completed"),
)
.await??;
assert_eq!(completed.turn.id, turn.id);
assert_eq!(completed.turn.status, TurnStatus::Completed);
let ThreadReadResponse { thread, .. } = mcp
.request(|request_id| ClientRequest::ThreadRead {
request_id,
@@ -332,10 +390,160 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> {
.await?;
assert_eq!(thread.turns.len(), 1);
assert_no_command_executions(&thread.turns[0].items, "thread/read");
assert!(
mcp.pending_notification_methods()
.iter()
.all(|method| { method != "turn/started" && method != "turn/completed" })
);
Ok(())
}
#[tokio::test]
async fn thread_shell_command_honors_optional_timeout() -> Result<()> {
let codex_home = TempDir::new()?;
let server = create_mock_responses_server_sequence(vec![]).await;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
// thread/shellCommand always executes on the app-server host.
.without_auto_env()
.build_initialized()
.await?;
let ThreadStartResponse { thread, .. } = mcp
.request(|request_id| ClientRequest::ThreadStart {
request_id,
params: ThreadStartParams::default(),
})
.await?;
for (timeout_ms, expires) in [
(None, false),
(Some(json!(null)), false),
(Some(json!(28_800_000)), false),
(Some(json!(3_000)), true),
(Some(json!(0)), true),
] {
let expect_output = timeout_ms != Some(json!(0));
let (command, expected_output) = if expires {
current_shell_slow_command()?
} else {
current_shell_output_command("shell-timeout-ready")?
};
let mut params = json!({ "threadId": thread.id, "command": command });
if let Some(timeout_ms) = timeout_ms {
params["timeoutMs"] = timeout_ms;
}
let request_id = mcp
.send_request("thread/shellCommand", Some(params))
.await?;
let _: ThreadShellCommandResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??;
let started: TurnStartedNotification = mcp.read_notification("turn/started").await?;
let completed =
wait_for_command_execution_completed(&mut mcp, /*expected_id*/ None).await?;
assert_eq!(completed.turn_id, started.turn.id);
let ThreadItem::CommandExecution {
status,
exit_code,
aggregated_output,
..
} = completed.item
else {
unreachable!("helper returns command execution item");
};
let output = aggregated_output.expect("shell output");
if expires {
assert_eq!(status, CommandExecutionStatus::Failed);
assert_eq!(exit_code, Some(-1));
assert!(output.contains("Timeout"));
if expect_output {
assert!(output.contains(expected_output.trim()));
}
} else {
assert_eq!(status, CommandExecutionStatus::Completed);
assert_eq!(exit_code, Some(0));
assert_eq!(output, expected_output);
}
let completed: TurnCompletedNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_notification("turn/completed"),
)
.await??;
assert_eq!(completed.turn.id, started.turn.id);
assert_eq!(completed.turn.status, TurnStatus::Completed);
}
Ok(())
}
#[tokio::test]
async fn thread_shell_command_rejects_invalid_timeouts_before_execution() -> Result<()> {
let codex_home = TempDir::new()?;
let server = create_mock_responses_server_sequence(vec![]).await;
MockResponsesConfig::new(&server.uri()).write(codex_home.path())?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.without_auto_env()
.build_initialized()
.await?;
let ThreadStartResponse { thread, .. } = mcp
.request(|request_id| ClientRequest::ThreadStart {
request_id,
params: ThreadStartParams::default(),
})
.await?;
for timeout_ms in [json!(-1), json!(1.5), json!("1000"), json!(u64::MAX)] {
let request_id = mcp
.send_request(
"thread/shellCommand",
Some(json!({
"threadId": thread.id,
"command": "echo unexpected",
"timeoutMs": timeout_ms,
})),
)
.await?;
let error = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
if timeout_ms == json!(-1) {
assert_eq!(error.error.code, INVALID_PARAMS_ERROR_CODE);
assert_eq!(
error.error.message,
"thread/shellCommand timeoutMs must be non-negative, got -1"
);
}
}
let ThreadReadResponse { thread, .. } = mcp
.request(|request_id| ClientRequest::ThreadRead {
request_id,
params: ThreadReadParams {
thread_id: thread.id,
include_turns: false,
},
})
.await?;
assert_eq!(thread.status, ThreadStatus::Idle);
assert!(
mcp.pending_notification_methods()
.iter()
.all(|method| { method != "turn/started" && method != "item/started" })
);
Ok(())
}
fn current_shell_slow_command() -> Result<(String, String)> {
let (command, expected_output) = current_shell_output_command("shell-timeout-ready")?;
let suffix = match default_user_shell().name() {
"powershell" => "; Start-Sleep -Seconds 60",
"cmd" => " & ping -n 61 127.0.0.1 > nul",
_ => "; exec sleep 60",
};
Ok((format!("{command}{suffix}"), expected_output))
}
fn assert_no_command_executions(items: &[ThreadItem], context: &str) {
assert!(
items
@@ -4380,6 +4380,7 @@ async fn direct_input_to_multi_agent_v2_subagent_is_rejected(
.send_thread_shell_command_request(ThreadShellCommandParams {
thread_id: child_thread_id.clone(),
command: "echo blocked".to_string(),
timeout_ms: None,
})
.await?;
let direct_shell_error: JSONRPCError = timeout(
+18 -5
View File
@@ -95,7 +95,12 @@ pub async fn inter_agent_communication(
}
}
pub async fn run_user_shell_command(sess: &Arc<Session>, sub_id: String, command: String) {
pub async fn run_user_shell_command(
sess: &Arc<Session>,
sub_id: String,
command: String,
timeout_ms: Option<u64>,
) {
if let Some((turn_context, cancellation_token)) =
sess.active_turn_context_and_cancellation_token().await
{
@@ -105,6 +110,7 @@ pub async fn run_user_shell_command(sess: &Arc<Session>, sub_id: String, command
session,
turn_context,
command,
timeout_ms,
cancellation_token,
UserShellCommandMode::ActiveTurnAuxiliary,
)
@@ -116,8 +122,12 @@ pub async fn run_user_shell_command(sess: &Arc<Session>, sub_id: String, command
let turn_context = sess
.new_turn_with_default_settings(sub_id, Default::default())
.await;
sess.spawn_task(turn_context, Vec::new(), UserShellCommandTask::new(command))
.await;
sess.spawn_task(
turn_context,
Vec::new(),
UserShellCommandTask::new(command, timeout_ms),
)
.await;
}
pub async fn resolve_elicitation(
@@ -675,8 +685,11 @@ pub(super) async fn submission_loop(
set_thread_memory_mode(&sess, sub.id.clone(), mode).await;
false
}
Op::RunUserShellCommand { command } => {
run_user_shell_command(&sess, sub.id.clone(), command).await;
Op::RunUserShellCommand {
command,
timeout_ms,
} => {
run_user_shell_command(&sess, sub.id.clone(), command, timeout_ms).await;
false
}
Op::ResolveElicitation {
+9 -2
View File
@@ -1549,6 +1549,7 @@ async fn user_shell_commands_do_not_inherit_managed_network_proxy() -> anyhow::R
Arc::clone(&session),
turn_context,
command,
/*timeout_ms*/ None,
CancellationToken::new(),
UserShellCommandMode::StandaloneTurn,
)
@@ -1583,6 +1584,7 @@ async fn user_shell_commands_remain_login_shells_when_model_login_shells_are_dis
Arc::clone(&session),
turn_context,
command,
/*timeout_ms*/ None,
CancellationToken::new(),
UserShellCommandMode::StandaloneTurn,
)
@@ -10568,8 +10570,13 @@ async fn run_user_shell_command_does_not_set_reference_context_item() {
state.set_reference_context_item(/*item*/ None);
}
handlers::run_user_shell_command(&session, "sub-id".to_string(), "echo shell".to_string())
.await;
handlers::run_user_shell_command(
&session,
"sub-id".to_string(),
"echo shell".to_string(),
/*timeout_ms*/ None,
)
.await;
let deadline = StdDuration::from_secs(15);
let start = std::time::Instant::now();
+9 -5
View File
@@ -61,11 +61,15 @@ pub(crate) enum UserShellCommandMode {
#[derive(Clone)]
pub(crate) struct UserShellCommandTask {
command: String,
timeout_ms: Option<u64>,
}
impl UserShellCommandTask {
pub(crate) fn new(command: String) -> Self {
Self { command }
pub(crate) fn new(command: String, timeout_ms: Option<u64>) -> Self {
Self {
command,
timeout_ms,
}
}
}
@@ -89,6 +93,7 @@ impl SessionTask for UserShellCommandTask {
session,
turn_context,
self.command.clone(),
self.timeout_ms,
cancellation_token,
UserShellCommandMode::StandaloneTurn,
)
@@ -101,6 +106,7 @@ pub(crate) async fn execute_user_shell_command(
session: Arc<Session>,
turn_context: Arc<TurnContext>,
command: String,
timeout_ms: Option<u64>,
cancellation_token: CancellationToken,
mode: UserShellCommandMode,
) {
@@ -212,9 +218,7 @@ pub(crate) async fn execute_user_shell_command(
// inherit a managed proxy from the surrounding session or turn.
network: None,
network_environment_id: None,
// TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we
// should use that instead of an "arbitrarily large" timeout here.
expiration: USER_SHELL_TIMEOUT_MS.into(),
expiration: timeout_ms.unwrap_or(USER_SHELL_TIMEOUT_MS).into(),
capture_policy: ExecCapturePolicy::ShellTool,
sandbox: SandboxType::None,
windows_sandbox_policy_cwd: cwd.clone().into(),
@@ -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 {
+109 -12
View File
@@ -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?;
+2
View File
@@ -739,6 +739,8 @@ pub enum Op {
RunUserShellCommand {
/// The raw command string after '!'
command: String,
/// Maximum execution time in milliseconds. Defaults to one hour.
timeout_ms: Option<u64>,
},
}
+1
View File
@@ -1445,6 +1445,7 @@ impl AppServerSession {
params: ThreadShellCommandParams {
thread_id: thread_id.to_string(),
command,
timeout_ms: None,
},
})
.await