Compare commits

...

3 Commits

Author SHA1 Message Date
Kazuhiro Sera
d489d10ec1 fix tests 2025-08-25 10:09:24 +09:00
Kazuhiro Sera
cea7457251 cargo fmt 2025-08-25 10:01:46 +09:00
Kazuhiro Sera
793a798c31 Fix #2346 by adding customizable exec_timeout_ms option for commands and tools 2025-08-25 09:56:51 +09:00
4 changed files with 178 additions and 5 deletions

View File

@@ -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.:

View File

@@ -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 perturn 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,156 @@ 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 codex_login::AuthManager;
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<Session>, 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 auth_manager =
AuthManager::shared(config.codex_home.clone(), config.preferred_auth_method);
let (session, mut turn_context) = Session::new(
configure_session,
Arc::new(config),
auth_manager,
tx_event,
None,
)
.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 {
@@ -2870,7 +3023,7 @@ fn convert_call_tool_result_to_function_call_output_payload(
}
#[cfg(test)]
mod tests {
mod tests_content {
use super::*;
use mcp_types::ContentBlock;
use mcp_types::TextContent;

View File

@@ -7,6 +7,7 @@ use crate::config_types::ShellEnvironmentPolicyToml;
use crate::config_types::Tui;
use crate::config_types::UriBasedFileOpener;
use crate::config_types::Verbosity;
use crate::exec::DEFAULT_TIMEOUT_MS;
use crate::git_info::resolve_root_git_project_for_trust;
use crate::model_family::ModelFamily;
use crate::model_family::find_family_for_model;
@@ -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<u64>,
/// Sandbox mode to use.
pub sandbox_mode: Option<SandboxMode>,
@@ -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,

View File

@@ -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.