Skip to content

Commit 28bfbb8

Browse files
Enforce user input length cap (#12823)
Currently there is no bound on the length of a user message submitted in the TUI or through the app server interface. That means users can paste many megabytes of text, which can lead to bad performance, hangs, and crashes. In extreme cases, it can lead to a [kernel panic](#12323). This PR limits the length of a user input to 2**20 (about 1M) characters. This value was chosen because it fills the entire context window on the latest models, so accepting longer inputs wouldn't make sense anyway. Summary - add a shared `MAX_USER_INPUT_TEXT_CHARS` constant in codex-protocol and surface it in TUI and app server code - block oversized submissions in the TUI submit flow and emit error history cells when validation fails - reject heavy app-server requests with JSON-RPC `-32602` and structured `input_too_large` data, plus document the behavior Testing - ran the IDE extension with this change and verified that when I attempt to paste a user message that's several MB long, it correctly reports an error instead of crashing or making my computer hot.
1 parent 9a96b6f commit 28bfbb8

File tree

13 files changed

+676
-0
lines changed

13 files changed

+676
-0
lines changed

codex-rs/app-server-protocol/src/protocol/v1.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,15 @@ impl From<V1TextElement> for CoreTextElement {
531531
}
532532
}
533533

534+
impl InputItem {
535+
pub fn text_char_count(&self) -> usize {
536+
match self {
537+
InputItem::Text { text, .. } => text.chars().count(),
538+
InputItem::Image { .. } | InputItem::LocalImage { .. } => 0,
539+
}
540+
}
541+
}
542+
534543
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
535544
#[serde(rename_all = "camelCase")]
536545
/// Deprecated in favor of AccountLoginCompletedNotification.

codex-rs/app-server-protocol/src/protocol/v2.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3052,6 +3052,18 @@ impl From<CoreUserInput> for UserInput {
30523052
}
30533053
}
30543054

3055+
impl UserInput {
3056+
pub fn text_char_count(&self) -> usize {
3057+
match self {
3058+
UserInput::Text { text, .. } => text.chars().count(),
3059+
UserInput::Image { .. }
3060+
| UserInput::LocalImage { .. }
3061+
| UserInput::Skill { .. }
3062+
| UserInput::Mention { .. } => 0,
3063+
}
3064+
}
3065+
}
3066+
30553067
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
30563068
#[serde(tag = "type", rename_all = "camelCase")]
30573069
#[ts(tag = "type")]

codex-rs/app-server/src/codex_message_processor.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
use crate::bespoke_event_handling::apply_bespoke_event_handling;
2+
use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE;
23
use crate::error_code::INTERNAL_ERROR_CODE;
4+
use crate::error_code::INVALID_PARAMS_ERROR_CODE;
35
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
46
use crate::fuzzy_file_search::FuzzyFileSearchSession;
57
use crate::fuzzy_file_search::run_fuzzy_file_search;
@@ -267,6 +269,7 @@ use codex_protocol::protocol::RolloutItem;
267269
use codex_protocol::protocol::SessionConfiguredEvent;
268270
use codex_protocol::protocol::SessionMetaLine;
269271
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
272+
use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
270273
use codex_protocol::user_input::UserInput as CoreInputItem;
271274
use codex_rmcp_client::perform_oauth_login_return_url;
272275
use codex_utils_json_to_toml::json_to_toml;
@@ -4735,6 +4738,36 @@ impl CodexMessageProcessor {
47354738
self.outgoing.send_error(request_id, error).await;
47364739
}
47374740

4741+
fn input_too_large_error(actual_chars: usize) -> JSONRPCErrorError {
4742+
JSONRPCErrorError {
4743+
code: INVALID_PARAMS_ERROR_CODE,
4744+
message: format!(
4745+
"Input exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters."
4746+
),
4747+
data: Some(serde_json::json!({
4748+
"input_error_code": INPUT_TOO_LARGE_ERROR_CODE,
4749+
"max_chars": MAX_USER_INPUT_TEXT_CHARS,
4750+
"actual_chars": actual_chars,
4751+
})),
4752+
}
4753+
}
4754+
4755+
fn validate_v1_input_limit(items: &[WireInputItem]) -> Result<(), JSONRPCErrorError> {
4756+
let actual_chars: usize = items.iter().map(WireInputItem::text_char_count).sum();
4757+
if actual_chars > MAX_USER_INPUT_TEXT_CHARS {
4758+
return Err(Self::input_too_large_error(actual_chars));
4759+
}
4760+
Ok(())
4761+
}
4762+
4763+
fn validate_v2_input_limit(items: &[V2UserInput]) -> Result<(), JSONRPCErrorError> {
4764+
let actual_chars: usize = items.iter().map(V2UserInput::text_char_count).sum();
4765+
if actual_chars > MAX_USER_INPUT_TEXT_CHARS {
4766+
return Err(Self::input_too_large_error(actual_chars));
4767+
}
4768+
Ok(())
4769+
}
4770+
47384771
async fn send_internal_error(&self, request_id: ConnectionRequestId, message: String) {
47394772
let error = JSONRPCErrorError {
47404773
code: INTERNAL_ERROR_CODE,
@@ -5034,6 +5067,10 @@ impl CodexMessageProcessor {
50345067
conversation_id,
50355068
items,
50365069
} = params;
5070+
if let Err(error) = Self::validate_v1_input_limit(&items) {
5071+
self.outgoing.send_error(request_id, error).await;
5072+
return;
5073+
}
50375074
let Ok(conversation) = self.thread_manager.get_thread(conversation_id).await else {
50385075
let error = JSONRPCErrorError {
50395076
code: INVALID_REQUEST_ERROR_CODE,
@@ -5085,6 +5122,10 @@ impl CodexMessageProcessor {
50855122
summary,
50865123
output_schema,
50875124
} = params;
5125+
if let Err(error) = Self::validate_v1_input_limit(&items) {
5126+
self.outgoing.send_error(request_id, error).await;
5127+
return;
5128+
}
50885129

50895130
let Ok(conversation) = self.thread_manager.get_thread(conversation_id).await else {
50905131
let error = JSONRPCErrorError {
@@ -5567,6 +5608,10 @@ impl CodexMessageProcessor {
55675608
}
55685609

55695610
async fn turn_start(&self, request_id: ConnectionRequestId, params: TurnStartParams) {
5611+
if let Err(error) = Self::validate_v2_input_limit(&params.input) {
5612+
self.outgoing.send_error(request_id, error).await;
5613+
return;
5614+
}
55705615
let (_, thread) = match self.load_thread(&params.thread_id).await {
55715616
Ok(v) => v,
55725617
Err(error) => {
@@ -5672,6 +5717,10 @@ impl CodexMessageProcessor {
56725717
.await;
56735718
return;
56745719
}
5720+
if let Err(error) = Self::validate_v2_input_limit(&params.input) {
5721+
self.outgoing.send_error(request_id, error).await;
5722+
return;
5723+
}
56755724

56765725
let mapped_items: Vec<CoreInputItem> = params
56775726
.input
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
pub(crate) const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
2+
pub const INVALID_PARAMS_ERROR_CODE: i64 = -32602;
23
pub(crate) const INTERNAL_ERROR_CODE: i64 = -32603;
34
pub(crate) const OVERLOADED_ERROR_CODE: i64 = -32001;
5+
pub const INPUT_TOO_LARGE_ERROR_CODE: &str = "input_too_large";

codex-rs/app-server/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ mod thread_state;
6767
mod thread_status;
6868
mod transport;
6969

70+
pub use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE;
71+
pub use crate::error_code::INVALID_PARAMS_ERROR_CODE;
7072
pub use crate::transport::AppServerTransport;
7173

7274
const LOG_FORMAT_ENV_VAR: &str = "LOG_FORMAT";

codex-rs/app-server/tests/suite/output_schema.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
use anyhow::Result;
22
use app_test_support::McpProcess;
33
use app_test_support::to_response;
4+
use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE;
5+
use codex_app_server::INVALID_PARAMS_ERROR_CODE;
46
use codex_app_server_protocol::AddConversationListenerParams;
57
use codex_app_server_protocol::InputItem;
8+
use codex_app_server_protocol::JSONRPCError;
69
use codex_app_server_protocol::JSONRPCResponse;
710
use codex_app_server_protocol::NewConversationParams;
811
use codex_app_server_protocol::NewConversationResponse;
@@ -13,6 +16,7 @@ use codex_protocol::config_types::ReasoningSummary;
1316
use codex_protocol::openai_models::ReasoningEffort;
1417
use codex_protocol::protocol::AskForApproval;
1518
use codex_protocol::protocol::SandboxPolicy;
19+
use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
1620
use core_test_support::responses;
1721
use core_test_support::skip_if_no_network;
1822
use pretty_assertions::assert_eq;
@@ -124,6 +128,85 @@ async fn send_user_turn_accepts_output_schema_v1() -> Result<()> {
124128
Ok(())
125129
}
126130

131+
#[tokio::test]
132+
async fn send_user_turn_rejects_oversized_input_v1() -> Result<()> {
133+
let server = responses::start_mock_server().await;
134+
let body = responses::sse(vec![
135+
responses::ev_response_created("resp-1"),
136+
responses::ev_assistant_message("msg-1", "Done"),
137+
responses::ev_completed("resp-1"),
138+
]);
139+
let _response_mock = responses::mount_sse_once(&server, body).await;
140+
141+
let codex_home = TempDir::new()?;
142+
create_config_toml(codex_home.path(), &server.uri())?;
143+
144+
let mut mcp = McpProcess::new(codex_home.path()).await?;
145+
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
146+
147+
let new_conv_id = mcp
148+
.send_new_conversation_request(NewConversationParams {
149+
..Default::default()
150+
})
151+
.await?;
152+
let new_conv_resp: JSONRPCResponse = timeout(
153+
DEFAULT_READ_TIMEOUT,
154+
mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)),
155+
)
156+
.await??;
157+
let NewConversationResponse {
158+
conversation_id, ..
159+
} = to_response::<NewConversationResponse>(new_conv_resp)?;
160+
161+
let listener_id = mcp
162+
.send_add_conversation_listener_request(AddConversationListenerParams {
163+
conversation_id,
164+
experimental_raw_events: false,
165+
})
166+
.await?;
167+
timeout(
168+
DEFAULT_READ_TIMEOUT,
169+
mcp.read_stream_until_response_message(RequestId::Integer(listener_id)),
170+
)
171+
.await??;
172+
173+
let oversized_input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1);
174+
let send_turn_id = mcp
175+
.send_send_user_turn_request(SendUserTurnParams {
176+
conversation_id,
177+
items: vec![InputItem::Text {
178+
text: oversized_input.clone(),
179+
text_elements: Vec::new(),
180+
}],
181+
cwd: codex_home.path().to_path_buf(),
182+
approval_policy: AskForApproval::Never,
183+
sandbox_policy: SandboxPolicy::DangerFullAccess,
184+
model: "mock-model".to_string(),
185+
effort: Some(ReasoningEffort::Low),
186+
summary: ReasoningSummary::Auto,
187+
output_schema: None,
188+
})
189+
.await?;
190+
191+
let err: JSONRPCError = timeout(
192+
DEFAULT_READ_TIMEOUT,
193+
mcp.read_stream_until_error_message(RequestId::Integer(send_turn_id)),
194+
)
195+
.await??;
196+
197+
assert_eq!(err.error.code, INVALID_PARAMS_ERROR_CODE);
198+
assert_eq!(
199+
err.error.message,
200+
format!("Input exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters.")
201+
);
202+
let data = err.error.data.expect("expected structured error data");
203+
assert_eq!(data["input_error_code"], INPUT_TOO_LARGE_ERROR_CODE);
204+
assert_eq!(data["max_chars"], MAX_USER_INPUT_TEXT_CHARS);
205+
assert_eq!(data["actual_chars"], oversized_input.chars().count());
206+
207+
Ok(())
208+
}
209+
127210
#[tokio::test]
128211
async fn send_user_turn_output_schema_is_per_turn_v1() -> Result<()> {
129212
skip_if_no_network!(Ok(()));

codex-rs/app-server/tests/suite/send_message.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ use app_test_support::McpProcess;
33
use app_test_support::create_fake_rollout;
44
use app_test_support::rollout_path;
55
use app_test_support::to_response;
6+
use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE;
7+
use codex_app_server::INVALID_PARAMS_ERROR_CODE;
68
use codex_app_server_protocol::AddConversationListenerParams;
79
use codex_app_server_protocol::AddConversationSubscriptionResponse;
810
use codex_app_server_protocol::InputItem;
11+
use codex_app_server_protocol::JSONRPCError;
912
use codex_app_server_protocol::JSONRPCNotification;
1013
use codex_app_server_protocol::JSONRPCResponse;
1114
use codex_app_server_protocol::NewConversationParams;
@@ -27,6 +30,7 @@ use codex_protocol::protocol::RolloutItem;
2730
use codex_protocol::protocol::RolloutLine;
2831
use codex_protocol::protocol::SandboxPolicy;
2932
use codex_protocol::protocol::TurnContextItem;
33+
use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
3034
use core_test_support::responses;
3135
use pretty_assertions::assert_eq;
3236
use std::io::Write;
@@ -272,6 +276,66 @@ async fn test_send_message_session_not_found() -> Result<()> {
272276
Ok(())
273277
}
274278

279+
#[tokio::test]
280+
async fn test_send_message_rejects_oversized_input() -> Result<()> {
281+
let server = responses::start_mock_server().await;
282+
let body = responses::sse(vec![
283+
responses::ev_response_created("resp-1"),
284+
responses::ev_assistant_message("msg-1", "Done"),
285+
responses::ev_completed("resp-1"),
286+
]);
287+
let _response_mock = responses::mount_sse_once(&server, body).await;
288+
289+
let codex_home = TempDir::new()?;
290+
create_config_toml(codex_home.path(), &server.uri())?;
291+
292+
let mut mcp = McpProcess::new(codex_home.path()).await?;
293+
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
294+
295+
let new_conv_id = mcp
296+
.send_new_conversation_request(NewConversationParams {
297+
..Default::default()
298+
})
299+
.await?;
300+
let new_conv_resp: JSONRPCResponse = timeout(
301+
DEFAULT_READ_TIMEOUT,
302+
mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)),
303+
)
304+
.await??;
305+
let NewConversationResponse {
306+
conversation_id, ..
307+
} = to_response::<_>(new_conv_resp)?;
308+
309+
let oversized_input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1);
310+
let req_id = mcp
311+
.send_send_user_message_request(SendUserMessageParams {
312+
conversation_id,
313+
items: vec![InputItem::Text {
314+
text: oversized_input.clone(),
315+
text_elements: Vec::new(),
316+
}],
317+
})
318+
.await?;
319+
320+
let err: JSONRPCError = timeout(
321+
DEFAULT_READ_TIMEOUT,
322+
mcp.read_stream_until_error_message(RequestId::Integer(req_id)),
323+
)
324+
.await??;
325+
326+
assert_eq!(err.error.code, INVALID_PARAMS_ERROR_CODE);
327+
assert_eq!(
328+
err.error.message,
329+
format!("Input exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters.")
330+
);
331+
let data = err.error.data.expect("expected structured error data");
332+
assert_eq!(data["input_error_code"], INPUT_TOO_LARGE_ERROR_CODE);
333+
assert_eq!(data["max_chars"], MAX_USER_INPUT_TEXT_CHARS);
334+
assert_eq!(data["actual_chars"], oversized_input.chars().count());
335+
336+
Ok(())
337+
}
338+
275339
#[tokio::test]
276340
async fn resume_with_model_mismatch_appends_model_switch_once() -> Result<()> {
277341
let server = responses::start_mock_server().await;

0 commit comments

Comments
 (0)