diff --git a/codex-rs/config.md b/codex-rs/config.md index a9d01dbc26..116fa2cb42 100644 --- a/codex-rs/config.md +++ b/codex-rs/config.md @@ -411,6 +411,14 @@ set = { PATH = "/usr/bin", MY_FLAG = "1" } Currently, `CODEX_SANDBOX_NETWORK_DISABLED=1` is also added to the environment, assuming network is disabled. This is not configurable. +## exec_timeout_ms + +Default timeout (in milliseconds) for commands and tool calls when an execution does not specify a `timeout`. The default is 10,000 milliseconds (10 seconds). + +```toml +exec_timeout_ms = 60000 # allow commands to run for up to 60s +``` + ## notify Specify a program that will be executed to get notified about events generated by Codex. Note that the program will receive the notification argument as a string of JSON, e.g.: diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index e175f55094..1d52d2d143 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -297,6 +297,7 @@ pub(crate) struct TurnContext { pub(crate) shell_environment_policy: ShellEnvironmentPolicy, pub(crate) disable_response_storage: bool, pub(crate) tools_config: ToolsConfig, + pub(crate) exec_timeout_ms: u64, } impl TurnContext { @@ -522,6 +523,7 @@ impl Session { shell_environment_policy: config.shell_environment_policy.clone(), cwd, disable_response_storage, + exec_timeout_ms: config.exec_timeout_ms, }; let sess = Arc::new(Session { session_id, @@ -1112,6 +1114,7 @@ async fn submission_loop( shell_environment_policy: prev.shell_environment_policy.clone(), cwd: new_cwd.clone(), disable_response_storage: prev.disable_response_storage, + exec_timeout_ms: prev.exec_timeout_ms, }; // Install the new persistent context for subsequent tasks/turns. @@ -1188,6 +1191,7 @@ async fn submission_loop( shell_environment_policy: turn_context.shell_environment_policy.clone(), cwd, disable_response_storage: turn_context.disable_response_storage, + exec_timeout_ms: turn_context.exec_timeout_ms, }; // TODO: record the new environment context in the conversation history // no current task, spawn a new one with the per‑turn context @@ -2145,8 +2149,7 @@ async fn handle_function_call( _ => { match sess.mcp_connection_manager.parse_tool_name(&name) { Some((server, tool_name)) => { - // TODO(mbolin): Determine appropriate timeout for tool call. - let timeout = None; + let timeout = Some(Duration::from_millis(turn_context.exec_timeout_ms)); handle_mcp_tool_call( sess, &sub_id, call_id, server, tool_name, arguments, timeout, ) @@ -2223,7 +2226,7 @@ fn to_exec_params(params: ShellToolCallParams, turn_context: &TurnContext) -> Ex ExecParams { command: params.command, cwd: turn_context.resolve_path(params.workdir.clone()), - timeout_ms: params.timeout_ms, + timeout_ms: params.timeout_ms.or(Some(turn_context.exec_timeout_ms)), env: create_env(&turn_context.shell_environment_policy), with_escalated_permissions: params.with_escalated_permissions, justification: params.justification, @@ -2834,6 +2837,148 @@ async fn drain_to_completed( } } +#[cfg(test)] +mod tests { + use super::*; + use crate::config::ConfigOverrides; + use crate::config::ConfigToml; + use crate::config_types::McpServerConfig; + use serde_json::json; + use std::sync::Arc; + use tempfile::TempDir; + + const DUMMY_MCP_SERVER: &str = r#" +import sys, json, time + +def send(msg): + sys.stdout.write(json.dumps(msg) + '\n') + sys.stdout.flush() + +while True: + line = sys.stdin.readline() + if not line: + break + req = json.loads(line) + method = req.get('method') + id_ = req.get('id') + if method == 'initialize': + send({ + 'jsonrpc': '2.0', + 'id': id_, + 'result': { + 'protocolVersion': '2025-06-18', + 'capabilities': {'tools': {'listChanged': True}}, + 'serverInfo': {'name': 'dummy', 'version': '0.0.0'} + } + }) + elif method == 'notifications/initialized': + pass + elif method == 'tools/list': + send({ + 'jsonrpc': '2.0', + 'id': id_, + 'result': { + 'tools': [{ + 'name': 'sleep', + 'description': 'sleep', + 'inputSchema': { + 'type': 'object', + 'properties': {'duration_ms': {'type': 'integer'}}, + 'required': ['duration_ms'] + } + }]} + }) + elif method == 'tools/call': + args = req.get('params', {}).get('arguments') or {} + duration_ms = args.get('duration_ms', 0) + time.sleep(duration_ms / 1000.0) + send({ + 'jsonrpc': '2.0', + 'id': id_, + 'result': { + 'content': [{'type': 'text', 'text': 'slept'}], + 'isError': False + } + }) + else: + send({'jsonrpc': '2.0', 'id': id_, 'error': {'code': -32601, 'message': 'unknown method'}}) +"#; + + async fn setup_session(exec_timeout_ms: u64) -> (Arc, TurnContext, TempDir) { + let tmp = TempDir::new().unwrap(); + let script_path = tmp.path().join("dummy_mcp_server.py"); + std::fs::write(&script_path, DUMMY_MCP_SERVER).unwrap(); + + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + tmp.path().to_path_buf(), + ) + .expect("defaults for test should always succeed"); + config.mcp_servers.insert( + "dummy".to_string(), + McpServerConfig { + command: "python3".to_string(), + args: vec!["-u".to_string(), script_path.to_string_lossy().into()], + env: None, + }, + ); + + let (tx_event, _rx_event) = async_channel::unbounded(); + let configure_session = ConfigureSession { + provider: config.model_provider.clone(), + model: config.model.clone(), + model_reasoning_effort: config.model_reasoning_effort, + model_reasoning_summary: config.model_reasoning_summary, + user_instructions: None, + base_instructions: config.base_instructions.clone(), + approval_policy: config.approval_policy, + sandbox_policy: config.sandbox_policy.clone(), + disable_response_storage: config.disable_response_storage, + notify: config.notify.clone(), + cwd: config.cwd.clone(), + resume_path: None, + }; + + let (session, mut turn_context) = + Session::new(configure_session, Arc::new(config), None, tx_event, exec_timeout_ms) + .await + .unwrap(); + turn_context.exec_timeout_ms = exec_timeout_ms; + (session, turn_context, tmp) + } + + #[tokio::test] + async fn mcp_tool_call_times_out() { + let (session, turn_context, _tmp) = setup_session(10).await; + let args = json!({ "duration_ms": 2000 }).to_string(); + let mut diff = TurnDiffTracker::default(); + let response = handle_function_call( + &session, + &turn_context, + &mut diff, + "sub".to_string(), + "dummy__sleep".to_string(), + args, + "call".to_string(), + ) + .await; + match response { + ResponseInputItem::McpToolCallOutput { result, .. } => { + assert!(result.is_err(), "expected timeout error, got: {result:?}"); + } + ResponseInputItem::FunctionCallOutput { output, .. } => { + // Windows OS + assert!( + output.success.is_none(), + "expected timeout error, got: {output:?}" + ); + } + other => panic!("unexpected response: {other:?}"), + } + } +} + fn convert_call_tool_result_to_function_call_output_payload( call_tool_result: &CallToolResult, ) -> FunctionCallOutputPayload { diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 98a8fde135..36c78c81f7 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -8,6 +8,7 @@ use crate::config_types::Tui; use crate::config_types::UriBasedFileOpener; use crate::config_types::Verbosity; use crate::git_info::resolve_root_git_project_for_trust; +use crate::exec::DEFAULT_TIMEOUT_MS; use crate::model_family::ModelFamily; use crate::model_family::find_family_for_model; use crate::model_provider_info::ModelProviderInfo; @@ -66,6 +67,9 @@ pub struct Config { pub shell_environment_policy: ShellEnvironmentPolicy, + /// Default timeout for `exec` commands (milliseconds) when a tool call does not specify one. + pub exec_timeout_ms: u64, + /// When `true`, `AgentReasoning` events emitted by the backend will be /// suppressed from the frontend output. This can reduce visual noise when /// users are only interested in the final agent responses. @@ -401,6 +405,9 @@ pub struct ConfigToml { #[serde(default)] pub shell_environment_policy: ShellEnvironmentPolicyToml, + /// Default timeout for exec commands (milliseconds). + pub exec_timeout_ms: Option, + /// Sandbox mode to use. pub sandbox_mode: Option, @@ -655,6 +662,7 @@ impl Config { .clone(); let shell_environment_policy = cfg.shell_environment_policy.clone().into(); + let exec_timeout_ms = cfg.exec_timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS); let resolved_cwd = { use std::env; @@ -739,6 +747,7 @@ impl Config { .unwrap_or_else(AskForApproval::default), sandbox_policy, shell_environment_policy, + exec_timeout_ms, disable_response_storage: config_profile .disable_response_storage .or(cfg.disable_response_storage) @@ -1126,6 +1135,7 @@ disable_response_storage = true approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), shell_environment_policy: ShellEnvironmentPolicy::default(), + exec_timeout_ms: DEFAULT_TIMEOUT_MS, disable_response_storage: false, user_instructions: None, notify: None, @@ -1182,6 +1192,7 @@ disable_response_storage = true approval_policy: AskForApproval::UnlessTrusted, sandbox_policy: SandboxPolicy::new_read_only_policy(), shell_environment_policy: ShellEnvironmentPolicy::default(), + exec_timeout_ms: DEFAULT_TIMEOUT_MS, disable_response_storage: false, user_instructions: None, notify: None, @@ -1253,6 +1264,7 @@ disable_response_storage = true approval_policy: AskForApproval::OnFailure, sandbox_policy: SandboxPolicy::new_read_only_policy(), shell_environment_policy: ShellEnvironmentPolicy::default(), + exec_timeout_ms: DEFAULT_TIMEOUT_MS, disable_response_storage: true, user_instructions: None, notify: None, diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index d74ec9fcd3..7bf5d7a93d 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -28,7 +28,7 @@ use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; use serde_bytes::ByteBuf; -const DEFAULT_TIMEOUT_MS: u64 = 10_000; +pub const DEFAULT_TIMEOUT_MS: u64 = 10_000; // Hardcode these since it does not seem worth including the libc crate just // for these.