diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 976f4e69d..719268e9c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -531,6 +531,15 @@ impl From for CoreTextElement { } } +impl InputItem { + pub fn text_char_count(&self) -> usize { + match self { + InputItem::Text { text, .. } => text.chars().count(), + InputItem::Image { .. } | InputItem::LocalImage { .. } => 0, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] /// Deprecated in favor of AccountLoginCompletedNotification. diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 6dba059eb..f7c4eec7a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3052,6 +3052,18 @@ impl From for UserInput { } } +impl UserInput { + pub fn text_char_count(&self) -> usize { + match self { + UserInput::Text { text, .. } => text.chars().count(), + UserInput::Image { .. } + | UserInput::LocalImage { .. } + | UserInput::Skill { .. } + | UserInput::Mention { .. } => 0, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type")] diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 594720aca..011fdbe72 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1,5 +1,7 @@ use crate::bespoke_event_handling::apply_bespoke_event_handling; +use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE; use crate::error_code::INTERNAL_ERROR_CODE; +use crate::error_code::INVALID_PARAMS_ERROR_CODE; use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::fuzzy_file_search::FuzzyFileSearchSession; use crate::fuzzy_file_search::run_fuzzy_file_search; @@ -267,6 +269,7 @@ use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::USER_MESSAGE_BEGIN; +use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use codex_protocol::user_input::UserInput as CoreInputItem; use codex_rmcp_client::perform_oauth_login_return_url; use codex_utils_json_to_toml::json_to_toml; @@ -4735,6 +4738,36 @@ impl CodexMessageProcessor { self.outgoing.send_error(request_id, error).await; } + fn input_too_large_error(actual_chars: usize) -> JSONRPCErrorError { + JSONRPCErrorError { + code: INVALID_PARAMS_ERROR_CODE, + message: format!( + "Input exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters." + ), + data: Some(serde_json::json!({ + "input_error_code": INPUT_TOO_LARGE_ERROR_CODE, + "max_chars": MAX_USER_INPUT_TEXT_CHARS, + "actual_chars": actual_chars, + })), + } + } + + fn validate_v1_input_limit(items: &[WireInputItem]) -> Result<(), JSONRPCErrorError> { + let actual_chars: usize = items.iter().map(WireInputItem::text_char_count).sum(); + if actual_chars > MAX_USER_INPUT_TEXT_CHARS { + return Err(Self::input_too_large_error(actual_chars)); + } + Ok(()) + } + + fn validate_v2_input_limit(items: &[V2UserInput]) -> Result<(), JSONRPCErrorError> { + let actual_chars: usize = items.iter().map(V2UserInput::text_char_count).sum(); + if actual_chars > MAX_USER_INPUT_TEXT_CHARS { + return Err(Self::input_too_large_error(actual_chars)); + } + Ok(()) + } + async fn send_internal_error(&self, request_id: ConnectionRequestId, message: String) { let error = JSONRPCErrorError { code: INTERNAL_ERROR_CODE, @@ -5034,6 +5067,10 @@ impl CodexMessageProcessor { conversation_id, items, } = params; + if let Err(error) = Self::validate_v1_input_limit(&items) { + self.outgoing.send_error(request_id, error).await; + return; + } let Ok(conversation) = self.thread_manager.get_thread(conversation_id).await else { let error = JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, @@ -5085,6 +5122,10 @@ impl CodexMessageProcessor { summary, output_schema, } = params; + if let Err(error) = Self::validate_v1_input_limit(&items) { + self.outgoing.send_error(request_id, error).await; + return; + } let Ok(conversation) = self.thread_manager.get_thread(conversation_id).await else { let error = JSONRPCErrorError { @@ -5567,6 +5608,10 @@ impl CodexMessageProcessor { } async fn turn_start(&self, request_id: ConnectionRequestId, params: TurnStartParams) { + if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.outgoing.send_error(request_id, error).await; + return; + } let (_, thread) = match self.load_thread(¶ms.thread_id).await { Ok(v) => v, Err(error) => { @@ -5672,6 +5717,10 @@ impl CodexMessageProcessor { .await; return; } + if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.outgoing.send_error(request_id, error).await; + return; + } let mapped_items: Vec = params .input diff --git a/codex-rs/app-server/src/error_code.rs b/codex-rs/app-server/src/error_code.rs index ca93b2f2d..924a7086a 100644 --- a/codex-rs/app-server/src/error_code.rs +++ b/codex-rs/app-server/src/error_code.rs @@ -1,3 +1,5 @@ pub(crate) const INVALID_REQUEST_ERROR_CODE: i64 = -32600; +pub const INVALID_PARAMS_ERROR_CODE: i64 = -32602; pub(crate) const INTERNAL_ERROR_CODE: i64 = -32603; pub(crate) const OVERLOADED_ERROR_CODE: i64 = -32001; +pub const INPUT_TOO_LARGE_ERROR_CODE: &str = "input_too_large"; diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 206435592..972f5af1e 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -67,6 +67,8 @@ mod thread_state; mod thread_status; mod transport; +pub use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE; +pub use crate::error_code::INVALID_PARAMS_ERROR_CODE; pub use crate::transport::AppServerTransport; const LOG_FORMAT_ENV_VAR: &str = "LOG_FORMAT"; diff --git a/codex-rs/app-server/tests/suite/output_schema.rs b/codex-rs/app-server/tests/suite/output_schema.rs index 3d83bec7d..db006e328 100644 --- a/codex-rs/app-server/tests/suite/output_schema.rs +++ b/codex-rs/app-server/tests/suite/output_schema.rs @@ -1,8 +1,11 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::to_response; +use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; +use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::AddConversationListenerParams; use codex_app_server_protocol::InputItem; +use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::NewConversationParams; use codex_app_server_protocol::NewConversationResponse; @@ -13,6 +16,7 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; @@ -124,6 +128,85 @@ async fn send_user_turn_accepts_output_schema_v1() -> Result<()> { Ok(()) } +#[tokio::test] +async fn send_user_turn_rejects_oversized_input_v1() -> Result<()> { + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let _response_mock = responses::mount_sse_once(&server, body).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let new_conv_id = mcp + .send_new_conversation_request(NewConversationParams { + ..Default::default() + }) + .await?; + let new_conv_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)), + ) + .await??; + let NewConversationResponse { + conversation_id, .. + } = to_response::(new_conv_resp)?; + + let listener_id = mcp + .send_add_conversation_listener_request(AddConversationListenerParams { + conversation_id, + experimental_raw_events: false, + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(listener_id)), + ) + .await??; + + let oversized_input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1); + let send_turn_id = mcp + .send_send_user_turn_request(SendUserTurnParams { + conversation_id, + items: vec![InputItem::Text { + text: oversized_input.clone(), + text_elements: Vec::new(), + }], + cwd: codex_home.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: "mock-model".to_string(), + effort: Some(ReasoningEffort::Low), + summary: ReasoningSummary::Auto, + output_schema: None, + }) + .await?; + + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(send_turn_id)), + ) + .await??; + + assert_eq!(err.error.code, INVALID_PARAMS_ERROR_CODE); + assert_eq!( + err.error.message, + format!("Input exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters.") + ); + let data = err.error.data.expect("expected structured error data"); + assert_eq!(data["input_error_code"], INPUT_TOO_LARGE_ERROR_CODE); + assert_eq!(data["max_chars"], MAX_USER_INPUT_TEXT_CHARS); + assert_eq!(data["actual_chars"], oversized_input.chars().count()); + + Ok(()) +} + #[tokio::test] async fn send_user_turn_output_schema_is_per_turn_v1() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/app-server/tests/suite/send_message.rs b/codex-rs/app-server/tests/suite/send_message.rs index 0bf6e98cf..5dc3c0dbc 100644 --- a/codex-rs/app-server/tests/suite/send_message.rs +++ b/codex-rs/app-server/tests/suite/send_message.rs @@ -3,9 +3,12 @@ use app_test_support::McpProcess; use app_test_support::create_fake_rollout; use app_test_support::rollout_path; use app_test_support::to_response; +use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; +use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::AddConversationListenerParams; use codex_app_server_protocol::AddConversationSubscriptionResponse; use codex_app_server_protocol::InputItem; +use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::NewConversationParams; @@ -27,6 +30,7 @@ use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TurnContextItem; +use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use pretty_assertions::assert_eq; use std::io::Write; @@ -272,6 +276,66 @@ async fn test_send_message_session_not_found() -> Result<()> { Ok(()) } +#[tokio::test] +async fn test_send_message_rejects_oversized_input() -> Result<()> { + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let _response_mock = responses::mount_sse_once(&server, body).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let new_conv_id = mcp + .send_new_conversation_request(NewConversationParams { + ..Default::default() + }) + .await?; + let new_conv_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)), + ) + .await??; + let NewConversationResponse { + conversation_id, .. + } = to_response::<_>(new_conv_resp)?; + + let oversized_input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1); + let req_id = mcp + .send_send_user_message_request(SendUserMessageParams { + conversation_id, + items: vec![InputItem::Text { + text: oversized_input.clone(), + text_elements: Vec::new(), + }], + }) + .await?; + + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(req_id)), + ) + .await??; + + assert_eq!(err.error.code, INVALID_PARAMS_ERROR_CODE); + assert_eq!( + err.error.message, + format!("Input exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters.") + ); + let data = err.error.data.expect("expected structured error data"); + assert_eq!(data["input_error_code"], INPUT_TOO_LARGE_ERROR_CODE); + assert_eq!(data["max_chars"], MAX_USER_INPUT_TEXT_CHARS); + assert_eq!(data["actual_chars"], oversized_input.chars().count()); + + Ok(()) +} + #[tokio::test] async fn resume_with_model_mismatch_appends_model_switch_once() -> Result<()> { let server = responses::start_mock_server().await; diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index afd116be1..f396aa8c4 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -9,6 +9,8 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::format_with_current_shell_display; use app_test_support::to_response; +use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; +use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::ByteRange; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::CommandExecutionApprovalDecision; @@ -19,6 +21,7 @@ use codex_app_server_protocol::FileChangeOutputDeltaNotification; use codex_app_server_protocol::FileChangeRequestApprovalResponse; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PatchApplyStatus; @@ -45,6 +48,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; @@ -223,6 +227,143 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::from([(Feature::Personality, true)]), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![ + V2UserInput::Text { + text: "x".repeat(MAX_USER_INPUT_TEXT_CHARS), + text_elements: Vec::new(), + }, + V2UserInput::Mention { + name: "Demo App".to_string(), + path: "app://demo-app".to_string(), + }, + ], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + assert_eq!(turn.status, TurnStatus::InProgress); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + +#[tokio::test] +async fn turn_start_rejects_combined_oversized_text_input() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + "http://localhost/unused", + "never", + &BTreeMap::from([(Feature::Personality, true)]), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let first = "x".repeat(MAX_USER_INPUT_TEXT_CHARS / 2); + let second = "y".repeat(MAX_USER_INPUT_TEXT_CHARS / 2 + 1); + let actual_chars = first.chars().count() + second.chars().count(); + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![ + V2UserInput::Text { + text: first, + text_elements: Vec::new(), + }, + V2UserInput::Text { + text: second, + text_elements: Vec::new(), + }, + ], + ..Default::default() + }) + .await?; + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(turn_req)), + ) + .await??; + + assert_eq!(err.error.code, INVALID_PARAMS_ERROR_CODE); + assert_eq!( + err.error.message, + format!("Input exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters.") + ); + let data = err.error.data.expect("expected structured error data"); + assert_eq!(data["input_error_code"], INPUT_TOO_LARGE_ERROR_CODE); + assert_eq!(data["max_chars"], MAX_USER_INPUT_TEXT_CHARS); + assert_eq!(data["actual_chars"], actual_chars); + + let turn_started = tokio::time::timeout( + std::time::Duration::from_millis(250), + mcp.read_stream_until_notification_message("turn/started"), + ) + .await; + assert!( + turn_started.is_err(), + "did not expect a turn/started notification for rejected input" + ); + + Ok(()) +} + #[tokio::test] async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<()> { // Provide a mock server and config so model wiring is valid. diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index 779e77577..61a8a9794 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -6,6 +6,8 @@ use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; +use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; +use codex_app_server::INVALID_PARAMS_ERROR_CODE; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; @@ -17,6 +19,7 @@ use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput as V2UserInput; +use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use tempfile::TempDir; use tokio::time::timeout; @@ -67,6 +70,109 @@ async fn turn_steer_requires_active_turn() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_steer_rejects_oversized_text_input() -> Result<()> { + #[cfg(target_os = "windows")] + let shell_command = vec![ + "powershell".to_string(), + "-Command".to_string(), + "Start-Sleep -Seconds 10".to_string(), + ]; + #[cfg(not(target_os = "windows"))] + let shell_command = vec!["sleep".to_string(), "10".to_string()]; + + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let working_directory = tmp.path().join("workdir"); + std::fs::create_dir(&working_directory)?; + + let server = + create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri())?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "run sleep".to_string(), + text_elements: Vec::new(), + }], + cwd: Some(working_directory.clone()), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let _task_started: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("codex/event/task_started"), + ) + .await??; + + let oversized_input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1); + let steer_req = mcp + .send_turn_steer_request(TurnSteerParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: oversized_input.clone(), + text_elements: Vec::new(), + }], + expected_turn_id: turn.id.clone(), + }) + .await?; + let steer_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(steer_req)), + ) + .await??; + + assert_eq!(steer_err.error.code, INVALID_PARAMS_ERROR_CODE); + assert_eq!( + steer_err.error.message, + format!("Input exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters.") + ); + let data = steer_err + .error + .data + .expect("expected structured error data"); + assert_eq!(data["input_error_code"], INPUT_TOO_LARGE_ERROR_CODE); + assert_eq!(data["max_chars"], MAX_USER_INPUT_TEXT_CHARS); + assert_eq!(data["actual_chars"], oversized_input.chars().count()); + + mcp.interrupt_turn_and_wait_for_aborted(thread.id, turn.id, DEFAULT_READ_TIMEOUT) + .await?; + + Ok(()) +} + #[tokio::test] async fn turn_steer_returns_active_turn_id() -> Result<()> { #[cfg(target_os = "windows")] diff --git a/codex-rs/protocol/src/user_input.rs b/codex-rs/protocol/src/user_input.rs index d40511f34..c1c22dcaf 100644 --- a/codex-rs/protocol/src/user_input.rs +++ b/codex-rs/protocol/src/user_input.rs @@ -3,6 +3,9 @@ use serde::Deserialize; use serde::Serialize; use ts_rs::TS; +/// Conservative cap so one user message cannot monopolize a large context window. +pub const MAX_USER_INPUT_TEXT_CHARS: usize = 1 << 20; + /// User input #[non_exhaustive] #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS, JsonSchema)] diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index e05953d2a..9943a2253 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -189,6 +189,7 @@ use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; use codex_protocol::models::local_image_label_text; use codex_protocol::user_input::ByteRange; +use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use codex_protocol::user_input::TextElement; use codex_utils_fuzzy_match::fuzzy_match; @@ -229,6 +230,12 @@ use tokio::runtime::Handle; /// placeholder in the UI. const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; +fn user_input_too_large_message(actual_chars: usize) -> String { + format!( + "Message exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters ({actual_chars} provided)." + ) +} + /// Result returned when the user interacts with the text area. #[derive(Debug, PartialEq)] pub enum InputResult { @@ -570,6 +577,10 @@ impl ChatComposer { self.realtime_conversation_enabled = enabled; } + /// Compatibility shim for tests that still toggle the removed steer mode flag. + #[cfg(test)] + pub fn set_steer_enabled(&mut self, _enabled: bool) {} + pub fn set_voice_transcription_enabled(&mut self, enabled: bool) { self.voice_state.transcription_enabled = enabled; if !enabled { @@ -2309,6 +2320,22 @@ impl ChatComposer { text_elements = expanded.text_elements; } } + let actual_chars = text.chars().count(); + if actual_chars > MAX_USER_INPUT_TEXT_CHARS { + let message = user_input_too_large_message(actual_chars); + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(message), + ))); + self.set_text_content_with_mention_bindings( + original_input.clone(), + original_text_elements, + original_local_image_paths, + original_mention_bindings, + ); + self.pending_pastes.clone_from(&original_pending_pastes); + self.textarea.set_cursor(original_input.len()); + return None; + } // Custom prompt expansion can remove or rewrite image placeholders, so prune any // attachments that no longer have a corresponding placeholder in the expanded text. self.prune_attached_images_for_submission(&text, &text_elements); @@ -5921,6 +5948,118 @@ mod tests { assert!(composer.pending_pastes.is_empty()); } + #[test] + fn submit_at_character_limit_succeeds() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + let input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS); + composer.textarea.set_text_clearing_elements(&input); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(matches!( + result, + InputResult::Submitted { text, .. } if text == input + )); + } + + #[test] + fn oversized_submit_reports_error_and_restores_draft() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, mut rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + let input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1); + composer.textarea.set_text_clearing_elements(&input); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(InputResult::None, result); + assert_eq!(composer.textarea.text(), input); + + let mut found_error = false; + while let Ok(event) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + let message = cell + .display_lines(80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert!(message.contains(&user_input_too_large_message(input.chars().count()))); + found_error = true; + break; + } + } + assert!(found_error, "expected oversized-input error history cell"); + } + + #[test] + fn oversized_queued_submission_reports_error_and_restores_draft() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, mut rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(false); + let input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1); + composer.textarea.set_text_clearing_elements(&input); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(InputResult::None, result); + assert_eq!(composer.textarea.text(), input); + + let mut found_error = false; + while let Ok(event) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + let message = cell + .display_lines(80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert!(message.contains(&user_input_too_large_message(input.chars().count()))); + found_error = true; + break; + } + } + assert!(found_error, "expected oversized-input error history cell"); + } + /// Behavior: editing that removes a paste placeholder should also clear the associated /// `pending_pastes` entry so it cannot be submitted accidentally. #[test] @@ -8521,6 +8660,57 @@ mod tests { ); } + #[test] + fn prompt_expansion_over_character_limit_reports_error_and_restores_draft() { + let (tx, mut rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Echo: $1".to_string(), + description: None, + argument_hint: None, + }]); + + let oversized_arg = "x".repeat(MAX_USER_INPUT_TEXT_CHARS); + let original_input = format!("/prompts:my-prompt {oversized_arg}"); + composer + .textarea + .set_text_clearing_elements(&original_input); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(InputResult::None, result); + assert_eq!(composer.textarea.text(), original_input); + + let actual_chars = format!("Echo: {oversized_arg}").chars().count(); + let mut found_error = false; + while let Ok(event) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + let message = cell + .display_lines(80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert!(message.contains(&user_input_too_large_message(actual_chars))); + found_error = true; + break; + } + } + assert!(found_error, "expected oversized-input error history cell"); + } + #[test] fn selecting_custom_prompt_with_positional_args_submits_numeric_expansion() { let prompt_text = "Header: $1\nArgs: $ARGUMENTS\n"; diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 78312db27..affbb7297 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -2602,6 +2602,16 @@ mod tests { insta::assert_snapshot!(rendered); } + #[test] + fn error_event_oversized_input_snapshot() { + let cell = new_error_event( + "Message exceeds the maximum length of 1048576 characters (1048577 provided)." + .to_string(), + ); + let rendered = render_lines(&cell.display_lines(120)).join("\n"); + insta::assert_snapshot!(rendered); + } + #[tokio::test] async fn mcp_tools_output_masks_sensitive_values() { let mut config = test_config().await; diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__error_event_oversized_input_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__error_event_oversized_input_snapshot.snap new file mode 100644 index 000000000..cf4b4134a --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__error_event_oversized_input_snapshot.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +■ Message exceeds the maximum length of 1048576 characters (1048577 provided).