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
@@ -4988,6 +4988,14 @@
|
|||||||
},
|
},
|
||||||
"threadId": {
|
"threadId": {
|
||||||
"type": "string"
|
"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": [
|
"required": [
|
||||||
|
|||||||
+8
@@ -23184,6 +23184,14 @@
|
|||||||
},
|
},
|
||||||
"threadId": {
|
"threadId": {
|
||||||
"type": "string"
|
"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": [
|
"required": [
|
||||||
|
|||||||
+8
@@ -20919,6 +20919,14 @@
|
|||||||
},
|
},
|
||||||
"threadId": {
|
"threadId": {
|
||||||
"type": "string"
|
"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": [
|
"required": [
|
||||||
|
|||||||
@@ -7,6 +7,14 @@
|
|||||||
},
|
},
|
||||||
"threadId": {
|
"threadId": {
|
||||||
"type": "string"
|
"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": [
|
"required": [
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+7
-1
@@ -9,4 +9,10 @@ export type ThreadShellCommandParams = { threadId: string,
|
|||||||
* such as pipes, redirects, and quoting. This runs unsandboxed with full
|
* such as pipes, redirects, and quoting. This runs unsandboxed with full
|
||||||
* access rather than inheriting the thread sandbox policy.
|
* 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]
|
#[test]
|
||||||
fn thread_shell_command_params_round_trip() {
|
fn thread_shell_command_params_round_trip() {
|
||||||
let params = ThreadShellCommandParams {
|
for timeout_ms in [None, Some(0), Some(28_800_000)] {
|
||||||
thread_id: "thr_123".to_string(),
|
let params = ThreadShellCommandParams {
|
||||||
command: "printf 'hello world\\n'".to_string(),
|
thread_id: "thr_123".to_string(),
|
||||||
};
|
command: "printf 'hello world\\n'".to_string(),
|
||||||
|
timeout_ms,
|
||||||
|
};
|
||||||
|
|
||||||
let value = serde_json::to_value(¶ms).expect("serialize thread/shellCommand params");
|
let value = serde_json::to_value(¶ms).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!(
|
assert_eq!(
|
||||||
value,
|
decoded,
|
||||||
json!({
|
ThreadShellCommandParams {
|
||||||
"threadId": "thr_123",
|
thread_id: "thr_123".to_string(),
|
||||||
"command": "printf 'hello world\\n'",
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -1129,6 +1129,12 @@ pub struct ThreadShellCommandParams {
|
|||||||
/// such as pipes, redirects, and quoting. This runs unsandboxed with full
|
/// such as pipes, redirects, and quoting. This runs unsandboxed with full
|
||||||
/// access rather than inheriting the thread sandbox policy.
|
/// access rather than inheriting the thread sandbox policy.
|
||||||
pub command: String,
|
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)]
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||||
|
|||||||
@@ -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
|
This API runs unsandboxed with full access; it does not inherit the thread
|
||||||
sandbox policy.
|
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", ... }`
|
- `item/started` with `item: { "type": "commandExecution", "source": "userShell", ... }`
|
||||||
- zero or more `item/commandExecution/outputDelta`
|
- 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": {} }
|
{ "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)
|
### 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:
|
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,
|
request_id: &ConnectionRequestId,
|
||||||
params: ThreadShellCommandParams,
|
params: ThreadShellCommandParams,
|
||||||
) -> Result<ThreadShellCommandResponse, JSONRPCErrorError> {
|
) -> Result<ThreadShellCommandResponse, JSONRPCErrorError> {
|
||||||
let ThreadShellCommandParams { thread_id, command } = params;
|
let ThreadShellCommandParams {
|
||||||
|
thread_id,
|
||||||
|
command,
|
||||||
|
timeout_ms,
|
||||||
|
} = params;
|
||||||
let command = command.trim().to_string();
|
let command = command.trim().to_string();
|
||||||
if command.is_empty() {
|
if command.is_empty() {
|
||||||
return Err(invalid_request("command must not be 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?;
|
let (_, thread) = self.load_thread(&thread_id).await?;
|
||||||
ensure_direct_input_allowed(thread.as_ref()).await?;
|
ensure_direct_input_allowed(thread.as_ref()).await?;
|
||||||
|
|
||||||
@@ -2449,7 +2463,10 @@ impl ThreadRequestProcessor {
|
|||||||
self.submit_core_op(
|
self.submit_core_op(
|
||||||
request_id,
|
request_id,
|
||||||
thread.as_ref(),
|
thread.as_ref(),
|
||||||
Op::RunUserShellCommand { command },
|
Op::RunUserShellCommand {
|
||||||
|
command,
|
||||||
|
timeout_ms,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| internal_error(format!("failed to start shell command: {err}")))?;
|
.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_final_assistant_message_sse_response;
|
||||||
use app_test_support::create_mock_responses_server_sequence;
|
use app_test_support::create_mock_responses_server_sequence;
|
||||||
use app_test_support::format_with_current_shell_display;
|
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::ClientRequest;
|
||||||
use codex_app_server_protocol::CommandExecutionApprovalDecision;
|
use codex_app_server_protocol::CommandExecutionApprovalDecision;
|
||||||
use codex_app_server_protocol::CommandExecutionOutputDeltaNotification;
|
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::ThreadShellCommandResponse;
|
||||||
use codex_app_server_protocol::ThreadStartParams;
|
use codex_app_server_protocol::ThreadStartParams;
|
||||||
use codex_app_server_protocol::ThreadStartResponse;
|
use codex_app_server_protocol::ThreadStartResponse;
|
||||||
|
use codex_app_server_protocol::ThreadStatus;
|
||||||
use codex_app_server_protocol::ThreadTurnsListParams;
|
use codex_app_server_protocol::ThreadTurnsListParams;
|
||||||
use codex_app_server_protocol::ThreadTurnsListResponse;
|
use codex_app_server_protocol::ThreadTurnsListResponse;
|
||||||
use codex_app_server_protocol::TurnCompletedNotification;
|
use codex_app_server_protocol::TurnCompletedNotification;
|
||||||
use codex_app_server_protocol::TurnStartParams;
|
use codex_app_server_protocol::TurnStartParams;
|
||||||
use codex_app_server_protocol::TurnStartResponse;
|
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_app_server_protocol::UserInput as V2UserInput;
|
||||||
use codex_core::shell::default_user_shell;
|
use codex_core::shell::default_user_shell;
|
||||||
use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR;
|
use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR;
|
||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
|
use serde_json::json;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
|
|
||||||
@@ -75,6 +80,7 @@ async fn thread_shell_command_history_responses_exclude_persisted_command_execut
|
|||||||
params: ThreadShellCommandParams {
|
params: ThreadShellCommandParams {
|
||||||
thread_id: thread.id.clone(),
|
thread_id: thread.id.clone(),
|
||||||
command: shell_command,
|
command: shell_command,
|
||||||
|
timeout_ms: None,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
@@ -187,6 +193,7 @@ async fn thread_shell_command_returns_error_when_local_environment_is_disabled()
|
|||||||
.send_thread_shell_command_request(ThreadShellCommandParams {
|
.send_thread_shell_command_request(ThreadShellCommandParams {
|
||||||
thread_id: thread.id,
|
thread_id: thread.id,
|
||||||
command: "pwd".to_string(),
|
command: "pwd".to_string(),
|
||||||
|
timeout_ms: None,
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
let error = mcp
|
let error = mcp
|
||||||
@@ -199,6 +206,15 @@ async fn thread_shell_command_returns_error_when_local_environment_is_disabled()
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn thread_shell_command_uses_existing_active_turn() -> Result<()> {
|
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 tmp = TempDir::new()?;
|
||||||
let codex_home = tmp.path().join("codex_home");
|
let codex_home = tmp.path().join("codex_home");
|
||||||
std::fs::create_dir(&codex_home)?;
|
std::fs::create_dir(&codex_home)?;
|
||||||
@@ -238,7 +254,11 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
.await?;
|
.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
|
let TurnStartResponse { turn } = mcp
|
||||||
.request(|request_id| ClientRequest::TurnStart {
|
.request(|request_id| ClientRequest::TurnStart {
|
||||||
@@ -256,6 +276,8 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> {
|
|||||||
})
|
})
|
||||||
.await?;
|
.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 agent_started = wait_for_command_execution_started(&mut mcp, Some("call-approve")).await?;
|
||||||
let ThreadItem::CommandExecution {
|
let ThreadItem::CommandExecution {
|
||||||
command, source, ..
|
command, source, ..
|
||||||
@@ -284,6 +306,7 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> {
|
|||||||
params: ThreadShellCommandParams {
|
params: ThreadShellCommandParams {
|
||||||
thread_id: thread.id.clone(),
|
thread_id: thread.id.clone(),
|
||||||
command: shell_command,
|
command: shell_command,
|
||||||
|
timeout_ms,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
@@ -301,13 +324,46 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> {
|
|||||||
let ThreadItem::CommandExecution {
|
let ThreadItem::CommandExecution {
|
||||||
source,
|
source,
|
||||||
aggregated_output,
|
aggregated_output,
|
||||||
|
status,
|
||||||
|
exit_code,
|
||||||
..
|
..
|
||||||
} = &completed.item
|
} = &completed.item
|
||||||
else {
|
else {
|
||||||
unreachable!("helper returns command execution item");
|
unreachable!("helper returns command execution item");
|
||||||
};
|
};
|
||||||
assert_eq!(source, &CommandExecutionSource::UserShell);
|
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(
|
mcp.send_response(
|
||||||
request_id,
|
request_id,
|
||||||
@@ -316,11 +372,13 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> {
|
|||||||
})?,
|
})?,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let _: TurnCompletedNotification = timeout(
|
let completed: TurnCompletedNotification = timeout(
|
||||||
DEFAULT_READ_TIMEOUT,
|
DEFAULT_READ_TIMEOUT,
|
||||||
mcp.read_notification("turn/completed"),
|
mcp.read_notification("turn/completed"),
|
||||||
)
|
)
|
||||||
.await??;
|
.await??;
|
||||||
|
assert_eq!(completed.turn.id, turn.id);
|
||||||
|
assert_eq!(completed.turn.status, TurnStatus::Completed);
|
||||||
let ThreadReadResponse { thread, .. } = mcp
|
let ThreadReadResponse { thread, .. } = mcp
|
||||||
.request(|request_id| ClientRequest::ThreadRead {
|
.request(|request_id| ClientRequest::ThreadRead {
|
||||||
request_id,
|
request_id,
|
||||||
@@ -332,10 +390,160 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> {
|
|||||||
.await?;
|
.await?;
|
||||||
assert_eq!(thread.turns.len(), 1);
|
assert_eq!(thread.turns.len(), 1);
|
||||||
assert_no_command_executions(&thread.turns[0].items, "thread/read");
|
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(())
|
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) {
|
fn assert_no_command_executions(items: &[ThreadItem], context: &str) {
|
||||||
assert!(
|
assert!(
|
||||||
items
|
items
|
||||||
|
|||||||
@@ -4380,6 +4380,7 @@ async fn direct_input_to_multi_agent_v2_subagent_is_rejected(
|
|||||||
.send_thread_shell_command_request(ThreadShellCommandParams {
|
.send_thread_shell_command_request(ThreadShellCommandParams {
|
||||||
thread_id: child_thread_id.clone(),
|
thread_id: child_thread_id.clone(),
|
||||||
command: "echo blocked".to_string(),
|
command: "echo blocked".to_string(),
|
||||||
|
timeout_ms: None,
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
let direct_shell_error: JSONRPCError = timeout(
|
let direct_shell_error: JSONRPCError = timeout(
|
||||||
|
|||||||
@@ -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)) =
|
if let Some((turn_context, cancellation_token)) =
|
||||||
sess.active_turn_context_and_cancellation_token().await
|
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,
|
session,
|
||||||
turn_context,
|
turn_context,
|
||||||
command,
|
command,
|
||||||
|
timeout_ms,
|
||||||
cancellation_token,
|
cancellation_token,
|
||||||
UserShellCommandMode::ActiveTurnAuxiliary,
|
UserShellCommandMode::ActiveTurnAuxiliary,
|
||||||
)
|
)
|
||||||
@@ -116,8 +122,12 @@ pub async fn run_user_shell_command(sess: &Arc<Session>, sub_id: String, command
|
|||||||
let turn_context = sess
|
let turn_context = sess
|
||||||
.new_turn_with_default_settings(sub_id, Default::default())
|
.new_turn_with_default_settings(sub_id, Default::default())
|
||||||
.await;
|
.await;
|
||||||
sess.spawn_task(turn_context, Vec::new(), UserShellCommandTask::new(command))
|
sess.spawn_task(
|
||||||
.await;
|
turn_context,
|
||||||
|
Vec::new(),
|
||||||
|
UserShellCommandTask::new(command, timeout_ms),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn resolve_elicitation(
|
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;
|
set_thread_memory_mode(&sess, sub.id.clone(), mode).await;
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
Op::RunUserShellCommand { command } => {
|
Op::RunUserShellCommand {
|
||||||
run_user_shell_command(&sess, sub.id.clone(), command).await;
|
command,
|
||||||
|
timeout_ms,
|
||||||
|
} => {
|
||||||
|
run_user_shell_command(&sess, sub.id.clone(), command, timeout_ms).await;
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
Op::ResolveElicitation {
|
Op::ResolveElicitation {
|
||||||
|
|||||||
@@ -1549,6 +1549,7 @@ async fn user_shell_commands_do_not_inherit_managed_network_proxy() -> anyhow::R
|
|||||||
Arc::clone(&session),
|
Arc::clone(&session),
|
||||||
turn_context,
|
turn_context,
|
||||||
command,
|
command,
|
||||||
|
/*timeout_ms*/ None,
|
||||||
CancellationToken::new(),
|
CancellationToken::new(),
|
||||||
UserShellCommandMode::StandaloneTurn,
|
UserShellCommandMode::StandaloneTurn,
|
||||||
)
|
)
|
||||||
@@ -1583,6 +1584,7 @@ async fn user_shell_commands_remain_login_shells_when_model_login_shells_are_dis
|
|||||||
Arc::clone(&session),
|
Arc::clone(&session),
|
||||||
turn_context,
|
turn_context,
|
||||||
command,
|
command,
|
||||||
|
/*timeout_ms*/ None,
|
||||||
CancellationToken::new(),
|
CancellationToken::new(),
|
||||||
UserShellCommandMode::StandaloneTurn,
|
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);
|
state.set_reference_context_item(/*item*/ None);
|
||||||
}
|
}
|
||||||
|
|
||||||
handlers::run_user_shell_command(&session, "sub-id".to_string(), "echo shell".to_string())
|
handlers::run_user_shell_command(
|
||||||
.await;
|
&session,
|
||||||
|
"sub-id".to_string(),
|
||||||
|
"echo shell".to_string(),
|
||||||
|
/*timeout_ms*/ None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let deadline = StdDuration::from_secs(15);
|
let deadline = StdDuration::from_secs(15);
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
|
|||||||
@@ -61,11 +61,15 @@ pub(crate) enum UserShellCommandMode {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub(crate) struct UserShellCommandTask {
|
pub(crate) struct UserShellCommandTask {
|
||||||
command: String,
|
command: String,
|
||||||
|
timeout_ms: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UserShellCommandTask {
|
impl UserShellCommandTask {
|
||||||
pub(crate) fn new(command: String) -> Self {
|
pub(crate) fn new(command: String, timeout_ms: Option<u64>) -> Self {
|
||||||
Self { command }
|
Self {
|
||||||
|
command,
|
||||||
|
timeout_ms,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +93,7 @@ impl SessionTask for UserShellCommandTask {
|
|||||||
session,
|
session,
|
||||||
turn_context,
|
turn_context,
|
||||||
self.command.clone(),
|
self.command.clone(),
|
||||||
|
self.timeout_ms,
|
||||||
cancellation_token,
|
cancellation_token,
|
||||||
UserShellCommandMode::StandaloneTurn,
|
UserShellCommandMode::StandaloneTurn,
|
||||||
)
|
)
|
||||||
@@ -101,6 +106,7 @@ pub(crate) async fn execute_user_shell_command(
|
|||||||
session: Arc<Session>,
|
session: Arc<Session>,
|
||||||
turn_context: Arc<TurnContext>,
|
turn_context: Arc<TurnContext>,
|
||||||
command: String,
|
command: String,
|
||||||
|
timeout_ms: Option<u64>,
|
||||||
cancellation_token: CancellationToken,
|
cancellation_token: CancellationToken,
|
||||||
mode: UserShellCommandMode,
|
mode: UserShellCommandMode,
|
||||||
) {
|
) {
|
||||||
@@ -212,9 +218,7 @@ pub(crate) async fn execute_user_shell_command(
|
|||||||
// inherit a managed proxy from the surrounding session or turn.
|
// inherit a managed proxy from the surrounding session or turn.
|
||||||
network: None,
|
network: None,
|
||||||
network_environment_id: None,
|
network_environment_id: None,
|
||||||
// TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we
|
expiration: timeout_ms.unwrap_or(USER_SHELL_TIMEOUT_MS).into(),
|
||||||
// should use that instead of an "arbitrarily large" timeout here.
|
|
||||||
expiration: USER_SHELL_TIMEOUT_MS.into(),
|
|
||||||
capture_policy: ExecCapturePolicy::ShellTool,
|
capture_policy: ExecCapturePolicy::ShellTool,
|
||||||
sandbox: SandboxType::None,
|
sandbox: SandboxType::None,
|
||||||
windows_sandbox_policy_cwd: cwd.clone().into(),
|
windows_sandbox_policy_cwd: cwd.clone().into(),
|
||||||
|
|||||||
@@ -385,6 +385,7 @@ async fn shell_snapshot_v2_preserves_legacy_snapshots_for_user_shell() -> Result
|
|||||||
codex
|
codex
|
||||||
.submit(Op::RunUserShellCommand {
|
.submit(Op::RunUserShellCommand {
|
||||||
command: "printf legacy".to_string(),
|
command: "printf legacy".to_string(),
|
||||||
|
timeout_ms: None,
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
let end = wait_for_event_match(codex, |event| match event {
|
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::Op;
|
||||||
use codex_protocol::protocol::ThreadSettingsOverrides;
|
use codex_protocol::protocol::ThreadSettingsOverrides;
|
||||||
use codex_protocol::protocol::TurnAbortReason;
|
use codex_protocol::protocol::TurnAbortReason;
|
||||||
|
use codex_protocol::protocol::TurnEnvironmentSelections;
|
||||||
use codex_protocol::user_input::UserInput;
|
use codex_protocol::user_input::UserInput;
|
||||||
use core_test_support::PathBufExt;
|
use core_test_support::PathBufExt;
|
||||||
|
use core_test_support::PathExt;
|
||||||
use core_test_support::assert_regex_match;
|
use core_test_support::assert_regex_match;
|
||||||
use core_test_support::responses;
|
use core_test_support::responses;
|
||||||
use core_test_support::responses::ev_assistant_message;
|
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::responses::start_mock_server;
|
||||||
use core_test_support::skip_if_no_network;
|
use core_test_support::skip_if_no_network;
|
||||||
use core_test_support::submit_thread_settings;
|
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::local_selections;
|
||||||
use core_test_support::test_codex::test_codex;
|
use core_test_support::test_codex::test_codex;
|
||||||
use core_test_support::test_codex::turn_permission_fields;
|
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
|
// 1) shell command should list the file
|
||||||
let list_cmd = "ls".to_string();
|
let list_cmd = "ls".to_string();
|
||||||
codex
|
codex
|
||||||
.submit(Op::RunUserShellCommand { command: list_cmd })
|
.submit(Op::RunUserShellCommand {
|
||||||
|
command: list_cmd,
|
||||||
|
timeout_ms: None,
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let msg = wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecCommandEnd(_))).await;
|
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
|
// 2) shell command should print the file contents verbatim
|
||||||
let cat_cmd = format!("cat {file_name}");
|
let cat_cmd = format!("cat {file_name}");
|
||||||
codex
|
codex
|
||||||
.submit(Op::RunUserShellCommand { command: cat_cmd })
|
.submit(Op::RunUserShellCommand {
|
||||||
|
command: cat_cmd,
|
||||||
|
timeout_ms: None,
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let msg = wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExecCommandEnd(_))).await;
|
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
|
test.codex
|
||||||
.submit(Op::RunUserShellCommand {
|
.submit(Op::RunUserShellCommand {
|
||||||
command: "echo shell".to_string(),
|
command: "echo shell".to_string(),
|
||||||
|
timeout_ms: None,
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -150,19 +160,19 @@ async fn user_shell_cmd_can_be_interrupted() {
|
|||||||
.expect("create new conversation");
|
.expect("create new conversation");
|
||||||
let codex = &fixture.codex;
|
let codex = &fixture.codex;
|
||||||
|
|
||||||
// Start a long-running command and then interrupt it.
|
// Start a long-running command and then interrupt it before its deadline.
|
||||||
let sleep_cmd = "sleep 5".to_string();
|
|
||||||
codex
|
codex
|
||||||
.submit(Op::RunUserShellCommand { command: sleep_cmd })
|
.submit(Op::RunUserShellCommand {
|
||||||
|
command: slow_user_shell_command().to_string(),
|
||||||
|
timeout_ms: Some(28_800_000),
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Wait until it has started (ExecCommandBegin), then interrupt.
|
// Output proves that the process was spawned before cancellation.
|
||||||
let _begin = wait_for_event_match(codex, |ev| match ev {
|
wait_for_event(codex, |event| {
|
||||||
EventMsg::ExecCommandBegin(event) if event.source == ExecCommandSource::UserShell => {
|
matches!(event, EventMsg::ExecCommandOutputDelta(delta)
|
||||||
Some(event.clone())
|
if String::from_utf8_lossy(&delta.chunk).contains("shell-timeout-ready"))
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
codex.submit(Op::Interrupt).await.unwrap();
|
codex.submit(Op::Interrupt).await.unwrap();
|
||||||
@@ -180,6 +190,87 @@ async fn user_shell_cmd_can_be_interrupted() {
|
|||||||
assert_eq!(ev.reason, TurnAbortReason::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)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> {
|
async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()> {
|
||||||
let server = start_mock_server().await;
|
let server = start_mock_server().await;
|
||||||
@@ -256,6 +347,7 @@ async fn user_shell_command_does_not_replace_active_turn() -> anyhow::Result<()>
|
|||||||
.codex
|
.codex
|
||||||
.submit(Op::RunUserShellCommand {
|
.submit(Op::RunUserShellCommand {
|
||||||
command: user_shell_command,
|
command: user_shell_command,
|
||||||
|
timeout_ms: None,
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -321,6 +413,7 @@ async fn user_shell_command_history_is_persisted_and_shared_with_model() -> anyh
|
|||||||
test.codex
|
test.codex
|
||||||
.submit(Op::RunUserShellCommand {
|
.submit(Op::RunUserShellCommand {
|
||||||
command: command.clone(),
|
command: command.clone(),
|
||||||
|
timeout_ms: None,
|
||||||
})
|
})
|
||||||
.await?;
|
.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();
|
r#"sh -c "printf '%s' \"${CODEX_SANDBOX_NETWORK_DISABLED:-not-set}\"""#.to_string();
|
||||||
|
|
||||||
test.codex
|
test.codex
|
||||||
.submit(Op::RunUserShellCommand { command })
|
.submit(Op::RunUserShellCommand {
|
||||||
|
command,
|
||||||
|
timeout_ms: None,
|
||||||
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let ExecCommandEndEvent {
|
let ExecCommandEndEvent {
|
||||||
@@ -449,6 +545,7 @@ async fn user_shell_command_output_is_truncated_in_history() -> anyhow::Result<(
|
|||||||
test.codex
|
test.codex
|
||||||
.submit(Op::RunUserShellCommand {
|
.submit(Op::RunUserShellCommand {
|
||||||
command: command.clone(),
|
command: command.clone(),
|
||||||
|
timeout_ms: None,
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -739,6 +739,8 @@ pub enum Op {
|
|||||||
RunUserShellCommand {
|
RunUserShellCommand {
|
||||||
/// The raw command string after '!'
|
/// The raw command string after '!'
|
||||||
command: String,
|
command: String,
|
||||||
|
/// Maximum execution time in milliseconds. Defaults to one hour.
|
||||||
|
timeout_ms: Option<u64>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1445,6 +1445,7 @@ impl AppServerSession {
|
|||||||
params: ThreadShellCommandParams {
|
params: ThreadShellCommandParams {
|
||||||
thread_id: thread_id.to_string(),
|
thread_id: thread_id.to_string(),
|
||||||
command,
|
command,
|
||||||
|
timeout_ms: None,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|||||||
Reference in new issue
Block a user