Compare commits

..

15 Commits

Author SHA1 Message Date
Ahmed Ibrahim
257d5eceff use shared tokenizer 2025-10-24 12:31:08 -07:00
Ahmed Ibrahim
56dfa60801 use shared tokenizer 2025-10-24 12:05:06 -07:00
Ahmed Ibrahim
50055c7ed5 use shared tokenizer 2025-10-24 12:02:48 -07:00
Ahmed Ibrahim
89e9bb1d42 comment 2025-10-24 11:44:05 -07:00
Ahmed Ibrahim
81d0003f0f tests 2025-10-24 11:36:36 -07:00
Ahmed Ibrahim
5d7983520b tests 2025-10-24 11:23:25 -07:00
Ahmed Ibrahim
4e7089a8ab default 2025-10-24 11:16:45 -07:00
Ahmed Ibrahim
697367cd3f bug 2025-10-24 10:12:36 -07:00
Ahmed Ibrahim
6ffbd0d4e3 bug 2025-10-24 10:08:47 -07:00
Ahmed Ibrahim
79c628a823 tests 2025-10-24 09:55:08 -07:00
Ahmed Ibrahim
9446de0923 tests 2025-10-23 23:26:02 -07:00
Ahmed Ibrahim
0f02954edb Merge branch 'main' into input-validation 2025-10-23 22:36:12 -07:00
Ahmed Ibrahim
7a8da22d7e add file 2025-10-23 19:03:24 -07:00
Ahmed Ibrahim
8f8ca17da0 input-validation 2025-10-23 18:58:08 -07:00
Ahmed Ibrahim
8b095d3cf1 add-input-validation 2025-10-23 16:17:10 -07:00
88 changed files with 740 additions and 2766 deletions

View File

@@ -33,8 +33,6 @@ Then simply run `codex` to get started:
codex
```
If you're running into upgrade issues with Homebrew, see the [FAQ entry on brew upgrade codex](./docs/faq.md#brew-update-codex-isnt-upgrading-me).
<details>
<summary>You can also go to the <a href="https://github.com/openai/codex/releases/latest">latest GitHub Release</a> and download the appropriate binary for your platform.</summary>

2
codex-rs/Cargo.lock generated
View File

@@ -853,7 +853,6 @@ dependencies = [
"pretty_assertions",
"serde",
"serde_json",
"serial_test",
"tempfile",
"tokio",
"toml",
@@ -1076,7 +1075,6 @@ dependencies = [
"escargot",
"eventsource-stream",
"futures",
"http",
"indexmap 2.10.0",
"landlock",
"libc",

View File

@@ -116,7 +116,6 @@ env_logger = "0.11.5"
escargot = "0.5"
eventsource-stream = "0.2.3"
futures = { version = "0.3", default-features = false }
http = "1.3.1"
icu_decimal = "2.0.0"
icu_locale_core = "2.0.0"
ignore = "0.4.23"

View File

@@ -23,7 +23,6 @@ use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use ts_rs::ExportError;
use ts_rs::TS;
const HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n";
@@ -105,19 +104,6 @@ macro_rules! for_each_schema_type {
};
}
fn export_ts_with_context<F>(label: &str, export: F) -> Result<()>
where
F: FnOnce() -> std::result::Result<(), ExportError>,
{
match export() {
Ok(()) => Ok(()),
Err(ExportError::CannotBeExported(ty)) => Err(anyhow!(
"failed to export {label}: dependency {ty} cannot be exported"
)),
Err(err) => Err(err.into()),
}
}
pub fn generate_types(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
generate_ts(out_dir, prettier)?;
generate_json(out_dir)?;
@@ -127,17 +113,13 @@ pub fn generate_types(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
ensure_dir(out_dir)?;
export_ts_with_context("ClientRequest", || ClientRequest::export_all_to(out_dir))?;
export_ts_with_context("client responses", || export_client_responses(out_dir))?;
export_ts_with_context("ClientNotification", || {
ClientNotification::export_all_to(out_dir)
})?;
ClientRequest::export_all_to(out_dir)?;
export_client_responses(out_dir)?;
ClientNotification::export_all_to(out_dir)?;
export_ts_with_context("ServerRequest", || ServerRequest::export_all_to(out_dir))?;
export_ts_with_context("server responses", || export_server_responses(out_dir))?;
export_ts_with_context("ServerNotification", || {
ServerNotification::export_all_to(out_dir)
})?;
ServerRequest::export_all_to(out_dir)?;
export_server_responses(out_dir)?;
ServerNotification::export_all_to(out_dir)?;
generate_index_ts(out_dir)?;

View File

@@ -17,7 +17,6 @@ use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SandboxCommandAssessment;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::TurnAbortReason;
use paste::paste;
@@ -128,7 +127,7 @@ client_request_definitions! {
#[ts(rename = "account/read")]
GetAccount {
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
response: GetAccountResponse,
response: Option<Account>,
},
/// DEPRECATED APIs below
@@ -535,12 +534,6 @@ pub struct GetAccountRateLimitsResponse {
pub rate_limits: RateLimitSnapshot,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(transparent)]
#[ts(export)]
#[ts(type = "Account | null")]
pub struct GetAccountResponse(#[ts(type = "Account | null")] pub Option<Account>);
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct GetAuthStatusResponse {
@@ -717,8 +710,6 @@ pub struct SendUserMessageResponse {}
#[serde(rename_all = "camelCase")]
pub struct AddConversationListenerParams {
pub conversation_id: ConversationId,
#[serde(default)]
pub experimental_raw_events: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -850,8 +841,6 @@ pub struct ExecCommandApprovalParams {
pub cwd: PathBuf,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub risk: Option<SandboxCommandAssessment>,
pub parsed_cmd: Vec<ParsedCommand>,
}
@@ -1068,7 +1057,6 @@ mod tests {
command: vec!["echo".to_string(), "hello".to_string()],
cwd: PathBuf::from("/tmp"),
reason: Some("because tests".to_string()),
risk: None,
parsed_cmd: vec![ParsedCommand::Unknown {
cmd: "echo hello".to_string(),
}],

View File

@@ -47,7 +47,6 @@ base64 = { workspace = true }
core_test_support = { workspace = true }
os_info = { workspace = true }
pretty_assertions = { workspace = true }
serial_test = { workspace = true }
tempfile = { workspace = true }
toml = { workspace = true }
wiremock = { workspace = true }

View File

@@ -1256,10 +1256,7 @@ impl CodexMessageProcessor {
request_id: RequestId,
params: AddConversationListenerParams,
) {
let AddConversationListenerParams {
conversation_id,
experimental_raw_events,
} = params;
let AddConversationListenerParams { conversation_id } = params;
let Ok(conversation) = self
.conversation_manager
.get_conversation(conversation_id)
@@ -1296,11 +1293,6 @@ impl CodexMessageProcessor {
}
};
if let EventMsg::RawResponseItem(_) = &event.msg
&& !experimental_raw_events {
continue;
}
// For now, we send a notification for every event,
// JSON-serializing the `Event` as-is, but these should
// be migrated to be variants of `ServerNotification`
@@ -1455,7 +1447,6 @@ async fn apply_bespoke_event_handling(
command,
cwd,
reason,
risk,
parsed_cmd,
}) => {
let params = ExecCommandApprovalParams {
@@ -1464,7 +1455,6 @@ async fn apply_bespoke_event_handling(
command,
cwd,
reason,
risk,
parsed_cmd,
};
let rx = outgoing
@@ -1533,7 +1523,6 @@ async fn derive_config_from_params(
include_view_image_tool: None,
show_raw_agent_reasoning: None,
tools_web_search_request: None,
experimental_sandbox_command_assessment: None,
additional_writable_roots: Vec::new(),
};

View File

@@ -103,10 +103,7 @@ async fn test_codex_jsonrpc_conversation_flow() {
// 2) addConversationListener
let add_listener_id = mcp
.send_add_conversation_listener_request(AddConversationListenerParams {
conversation_id,
experimental_raw_events: false,
})
.send_add_conversation_listener_request(AddConversationListenerParams { conversation_id })
.await
.expect("send addConversationListener");
let add_listener_resp: JSONRPCResponse = timeout(
@@ -255,10 +252,7 @@ async fn test_send_user_turn_changes_approval_policy_behavior() {
// 2) addConversationListener
let add_listener_id = mcp
.send_add_conversation_listener_request(AddConversationListenerParams {
conversation_id,
experimental_raw_events: false,
})
.send_add_conversation_listener_request(AddConversationListenerParams { conversation_id })
.await
.expect("send addConversationListener");
let _: AddConversationSubscriptionResponse =
@@ -317,7 +311,6 @@ async fn test_send_user_turn_changes_approval_policy_behavior() {
],
cwd: working_directory.clone(),
reason: None,
risk: None,
parsed_cmd: vec![ParsedCommand::Unknown {
cmd: "python3 -c 'print(42)'".to_string()
}],
@@ -465,10 +458,7 @@ async fn test_send_user_turn_updates_sandbox_and_cwd_between_turns() {
.expect("deserialize newConversation response");
let add_listener_id = mcp
.send_add_conversation_listener_request(AddConversationListenerParams {
conversation_id,
experimental_raw_events: false,
})
.send_add_conversation_listener_request(AddConversationListenerParams { conversation_id })
.await
.expect("send addConversationListener");
timeout(

View File

@@ -67,10 +67,7 @@ async fn test_conversation_create_and_send_message_ok() {
// Add a listener so we receive notifications for this conversation (not strictly required for this test).
let add_listener_id = mcp
.send_add_conversation_listener_request(AddConversationListenerParams {
conversation_id,
experimental_raw_events: false,
})
.send_add_conversation_listener_request(AddConversationListenerParams { conversation_id })
.await
.expect("send addConversationListener");
let _sub: AddConversationSubscriptionResponse =

View File

@@ -88,10 +88,7 @@ async fn shell_command_interruption() -> anyhow::Result<()> {
// 2) addConversationListener
let add_listener_id = mcp
.send_add_conversation_listener_request(AddConversationListenerParams {
conversation_id,
experimental_raw_events: false,
})
.send_add_conversation_listener_request(AddConversationListenerParams { conversation_id })
.await?;
let _add_listener_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,

View File

@@ -13,7 +13,6 @@ use codex_app_server_protocol::LoginChatGptResponse;
use codex_app_server_protocol::LogoutChatGptResponse;
use codex_app_server_protocol::RequestId;
use codex_login::login_with_api_key;
use serial_test::serial;
use tempfile::TempDir;
use tokio::time::timeout;
@@ -95,8 +94,6 @@ async fn logout_chatgpt_removes_auth() {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
// Serialize tests that launch the login server since it binds to a fixed port.
#[serial(login_port)]
async fn login_and_cancel_chatgpt() {
let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
create_config_toml(codex_home.path()).unwrap_or_else(|err| panic!("write config.toml: {err}"));
@@ -211,8 +208,6 @@ async fn login_chatgpt_rejected_when_forced_api() {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
// Serialize tests that launch the login server since it binds to a fixed port.
#[serial(login_port)]
async fn login_chatgpt_includes_forced_workspace_query_param() {
let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
create_config_toml_forced_workspace(codex_home.path(), "ws-forced")

View File

@@ -15,8 +15,6 @@ use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SendUserMessageParams;
use codex_app_server_protocol::SendUserMessageResponse;
use codex_protocol::ConversationId;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
@@ -64,10 +62,7 @@ async fn test_send_message_success() {
// 2) addConversationListener
let add_listener_id = mcp
.send_add_conversation_listener_request(AddConversationListenerParams {
conversation_id,
experimental_raw_events: false,
})
.send_add_conversation_listener_request(AddConversationListenerParams { conversation_id })
.await
.expect("send addConversationListener");
let add_listener_resp: JSONRPCResponse = timeout(
@@ -129,105 +124,6 @@ async fn send_message(message: &str, conversation_id: ConversationId, mcp: &mut
.expect("should have conversationId"),
&serde_json::Value::String(conversation_id.to_string())
);
let raw_attempt = tokio::time::timeout(
std::time::Duration::from_millis(200),
mcp.read_stream_until_notification_message("codex/event/raw_response_item"),
)
.await;
assert!(
raw_attempt.is_err(),
"unexpected raw item notification when not opted in"
);
}
#[tokio::test]
async fn test_send_message_raw_notifications_opt_in() {
let responses = vec![
create_final_assistant_message_sse_response("Done").expect("build mock assistant message"),
];
let server = create_mock_chat_completions_server(responses).await;
let codex_home = TempDir::new().expect("create temp dir");
create_config_toml(codex_home.path(), &server.uri()).expect("write config.toml");
let mut mcp = McpProcess::new(codex_home.path())
.await
.expect("spawn mcp process");
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize())
.await
.expect("init timed out")
.expect("init failed");
let new_conv_id = mcp
.send_new_conversation_request(NewConversationParams::default())
.await
.expect("send newConversation");
let new_conv_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)),
)
.await
.expect("newConversation timeout")
.expect("newConversation resp");
let NewConversationResponse {
conversation_id, ..
} = to_response::<_>(new_conv_resp).expect("deserialize newConversation response");
let add_listener_id = mcp
.send_add_conversation_listener_request(AddConversationListenerParams {
conversation_id,
experimental_raw_events: true,
})
.await
.expect("send addConversationListener");
let add_listener_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(add_listener_id)),
)
.await
.expect("addConversationListener timeout")
.expect("addConversationListener resp");
let AddConversationSubscriptionResponse { subscription_id: _ } =
to_response::<_>(add_listener_resp).expect("deserialize addConversationListener response");
let send_id = mcp
.send_send_user_message_request(SendUserMessageParams {
conversation_id,
items: vec![InputItem::Text {
text: "Hello".to_string(),
}],
})
.await
.expect("send sendUserMessage");
let instructions = read_raw_response_item(&mut mcp, conversation_id).await;
assert_instructions_message(&instructions);
let environment = read_raw_response_item(&mut mcp, conversation_id).await;
assert_environment_message(&environment);
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(send_id)),
)
.await
.expect("sendUserMessage response timeout")
.expect("sendUserMessage response error");
let _ok: SendUserMessageResponse = to_response::<SendUserMessageResponse>(response)
.expect("deserialize sendUserMessage response");
let user_message = read_raw_response_item(&mut mcp, conversation_id).await;
assert_user_message(&user_message, "Hello");
let assistant_message = read_raw_response_item(&mut mcp, conversation_id).await;
assert_assistant_message(&assistant_message, "Done");
let _ = tokio::time::timeout(
std::time::Duration::from_millis(250),
mcp.read_stream_until_notification_message("codex/event/task_complete"),
)
.await;
}
#[tokio::test]
@@ -288,108 +184,3 @@ stream_max_retries = 0
),
)
}
#[expect(clippy::expect_used)]
async fn read_raw_response_item(
mcp: &mut McpProcess,
conversation_id: ConversationId,
) -> ResponseItem {
let raw_notification: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/raw_response_item"),
)
.await
.expect("codex/event/raw_response_item notification timeout")
.expect("codex/event/raw_response_item notification resp");
let serde_json::Value::Object(params) = raw_notification
.params
.expect("codex/event/raw_response_item should have params")
else {
panic!("codex/event/raw_response_item should have params");
};
let conversation_id_value = params
.get("conversationId")
.and_then(|value| value.as_str())
.expect("raw response item should include conversationId");
assert_eq!(
conversation_id_value,
conversation_id.to_string(),
"raw response item conversation mismatch"
);
let msg_value = params
.get("msg")
.cloned()
.expect("raw response item should include msg payload");
serde_json::from_value(msg_value).expect("deserialize raw response item")
}
fn assert_instructions_message(item: &ResponseItem) {
match item {
ResponseItem::Message { role, content, .. } => {
assert_eq!(role, "user");
let texts = content_texts(content);
assert!(
texts
.iter()
.any(|text| text.contains("<user_instructions>")),
"expected instructions message, got {texts:?}"
);
}
other => panic!("expected instructions message, got {other:?}"),
}
}
fn assert_environment_message(item: &ResponseItem) {
match item {
ResponseItem::Message { role, content, .. } => {
assert_eq!(role, "user");
let texts = content_texts(content);
assert!(
texts
.iter()
.any(|text| text.contains("<environment_context>")),
"expected environment context message, got {texts:?}"
);
}
other => panic!("expected environment message, got {other:?}"),
}
}
fn assert_user_message(item: &ResponseItem, expected_text: &str) {
match item {
ResponseItem::Message { role, content, .. } => {
assert_eq!(role, "user");
let texts = content_texts(content);
assert_eq!(texts, vec![expected_text]);
}
other => panic!("expected user message, got {other:?}"),
}
}
fn assert_assistant_message(item: &ResponseItem, expected_text: &str) {
match item {
ResponseItem::Message { role, content, .. } => {
assert_eq!(role, "assistant");
let texts = content_texts(content);
assert_eq!(texts, vec![expected_text]);
}
other => panic!("expected assistant message, got {other:?}"),
}
}
fn content_texts(content: &[ContentItem]) -> Vec<&str> {
content
.iter()
.filter_map(|item| match item {
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
Some(text.as_str())
}
_ => None,
})
.collect()
}

View File

@@ -274,33 +274,19 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
http_headers,
env_http_headers,
} = transport
&& matches!(supports_oauth_login(&url).await, Ok(true))
{
match supports_oauth_login(&url).await {
Ok(true) => {
if !config.features.enabled(Feature::RmcpClient) {
println!(
"MCP server supports login. Add `experimental_use_rmcp_client = true` \
to your config.toml and run `codex mcp login {name}` to login."
);
} else {
println!("Detected OAuth support. Starting OAuth flow…");
perform_oauth_login(
&name,
&url,
config.mcp_oauth_credentials_store_mode,
http_headers.clone(),
env_http_headers.clone(),
&Vec::new(),
)
.await?;
println!("Successfully logged in.");
}
}
Ok(false) => {}
Err(_) => println!(
"MCP server may or may not require login. Run `codex mcp login {name}` to login."
),
}
println!("Detected OAuth support. Starting OAuth flow…");
perform_oauth_login(
&name,
&url,
config.mcp_oauth_credentials_store_mode,
http_headers.clone(),
env_http_headers.clone(),
&Vec::new(),
)
.await?;
println!("Successfully logged in.");
}
Ok(())
@@ -537,12 +523,10 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
.map(|entry| entry.auth_status)
.unwrap_or(McpAuthStatus::Unsupported)
.to_string();
let bearer_token_display =
bearer_token_env_var.as_deref().unwrap_or("-").to_string();
http_rows.push([
name.clone(),
url.clone(),
bearer_token_display,
bearer_token_env_var.clone().unwrap_or("-".to_string()),
status,
auth_status,
]);
@@ -768,15 +752,15 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re
} => {
println!(" transport: streamable_http");
println!(" url: {url}");
let bearer_token_display = bearer_token_env_var.as_deref().unwrap_or("-");
println!(" bearer_token_env_var: {bearer_token_display}");
let env_var = bearer_token_env_var.as_deref().unwrap_or("-");
println!(" bearer_token_env_var: {env_var}");
let headers_display = match http_headers {
Some(map) if !map.is_empty() => {
let mut pairs: Vec<_> = map.iter().collect();
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
pairs
.into_iter()
.map(|(k, _)| format!("{k}=*****"))
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(", ")
}
@@ -789,7 +773,7 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
pairs
.into_iter()
.map(|(k, var)| format!("{k}={var}"))
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(", ")
}

View File

@@ -68,9 +68,9 @@ async fn list_and_get_render_expected_output() -> Result<()> {
assert!(stdout.contains("Name"));
assert!(stdout.contains("docs"));
assert!(stdout.contains("docs-server"));
assert!(stdout.contains("TOKEN=*****"));
assert!(stdout.contains("APP_TOKEN=*****"));
assert!(stdout.contains("WORKSPACE_ID=*****"));
assert!(stdout.contains("TOKEN=secret"));
assert!(stdout.contains("APP_TOKEN=$APP_TOKEN"));
assert!(stdout.contains("WORKSPACE_ID=$WORKSPACE_ID"));
assert!(stdout.contains("Status"));
assert!(stdout.contains("Auth"));
assert!(stdout.contains("enabled"));
@@ -119,9 +119,9 @@ async fn list_and_get_render_expected_output() -> Result<()> {
assert!(stdout.contains("transport: stdio"));
assert!(stdout.contains("command: docs-server"));
assert!(stdout.contains("args: --port 4000"));
assert!(stdout.contains("env: TOKEN=*****"));
assert!(stdout.contains("APP_TOKEN=*****"));
assert!(stdout.contains("WORKSPACE_ID=*****"));
assert!(stdout.contains("env: TOKEN=secret"));
assert!(stdout.contains("APP_TOKEN=$APP_TOKEN"));
assert!(stdout.contains("WORKSPACE_ID=$WORKSPACE_ID"));
assert!(stdout.contains("enabled: true"));
assert!(stdout.contains("remove: codex mcp remove docs"));

View File

@@ -6,11 +6,15 @@ pub fn format_env_display(env: Option<&HashMap<String, String>>, env_vars: &[Str
if let Some(map) = env {
let mut pairs: Vec<_> = map.iter().collect();
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
parts.extend(pairs.into_iter().map(|(key, _)| format!("{key}=*****")));
parts.extend(
pairs
.into_iter()
.map(|(key, value)| format!("{key}={value}")),
);
}
if !env_vars.is_empty() {
parts.extend(env_vars.iter().map(|var| format!("{var}=*****")));
parts.extend(env_vars.iter().map(|var| format!("{var}=${var}")));
}
if parts.is_empty() {
@@ -38,14 +42,14 @@ mod tests {
env.insert("B".to_string(), "two".to_string());
env.insert("A".to_string(), "one".to_string());
assert_eq!(format_env_display(Some(&env), &[]), "A=*****, B=*****");
assert_eq!(format_env_display(Some(&env), &[]), "A=one, B=two");
}
#[test]
fn formats_env_vars_with_dollar_prefix() {
let vars = vec!["TOKEN".to_string(), "PATH".to_string()];
assert_eq!(format_env_display(None, &vars), "TOKEN=*****, PATH=*****");
assert_eq!(format_env_display(None, &vars), "TOKEN=$TOKEN, PATH=$PATH");
}
#[test]
@@ -56,7 +60,7 @@ mod tests {
assert_eq!(
format_env_display(Some(&env), &vars),
"HOME=*****, TOKEN=*****"
"HOME=/tmp, TOKEN=$TOKEN"
);
}
}

View File

@@ -34,7 +34,6 @@ dunce = { workspace = true }
env-flags = { workspace = true }
eventsource-stream = { workspace = true }
futures = { workspace = true }
http = { workspace = true }
indexmap = { workspace = true }
libc = { workspace = true }
mcp-types = { workspace = true }

View File

@@ -21,7 +21,6 @@ use codex_app_server_protocol::AuthMode;
use codex_protocol::config_types::ForcedLoginMethod;
use crate::config::Config;
use crate::default_client::CodexHttpClient;
use crate::token_data::PlanType;
use crate::token_data::TokenData;
use crate::token_data::parse_id_token;
@@ -33,7 +32,7 @@ pub struct CodexAuth {
pub(crate) api_key: Option<String>,
pub(crate) auth_dot_json: Arc<Mutex<Option<AuthDotJson>>>,
pub(crate) auth_file: PathBuf,
pub(crate) client: CodexHttpClient,
pub(crate) client: reqwest::Client,
}
impl PartialEq for CodexAuth {
@@ -44,8 +43,6 @@ impl PartialEq for CodexAuth {
impl CodexAuth {
pub async fn refresh_token(&self) -> Result<String, std::io::Error> {
tracing::info!("Refreshing token");
let token_data = self
.get_current_token_data()
.ok_or(std::io::Error::other("Token data is not available."))?;
@@ -183,7 +180,7 @@ impl CodexAuth {
}
}
fn from_api_key_with_client(api_key: &str, client: CodexHttpClient) -> Self {
fn from_api_key_with_client(api_key: &str, client: reqwest::Client) -> Self {
Self {
api_key: Some(api_key.to_owned()),
mode: AuthMode::ApiKey,
@@ -403,7 +400,7 @@ async fn update_tokens(
async fn try_refresh_token(
refresh_token: String,
client: &CodexHttpClient,
client: &reqwest::Client,
) -> std::io::Result<RefreshResponse> {
let refresh_request = RefreshRequest {
client_id: CLIENT_ID,
@@ -919,10 +916,7 @@ impl AuthManager {
self.reload();
Ok(Some(token))
}
Err(e) => {
tracing::error!("Failed to refresh token: {}", e);
Err(e)
}
Err(e) => Err(e),
}
}

View File

@@ -4,7 +4,6 @@ use crate::ModelProviderInfo;
use crate::client_common::Prompt;
use crate::client_common::ResponseEvent;
use crate::client_common::ResponseStream;
use crate::default_client::CodexHttpClient;
use crate::error::CodexErr;
use crate::error::ConnectionFailedError;
use crate::error::ResponseStreamFailed;
@@ -37,7 +36,7 @@ use tracing::trace;
pub(crate) async fn stream_chat_completions(
prompt: &Prompt,
model_family: &ModelFamily,
client: &CodexHttpClient,
client: &reqwest::Client,
provider: &ModelProviderInfo,
otel_event_manager: &OtelEventManager,
) -> Result<ResponseStream> {

View File

@@ -39,7 +39,6 @@ use crate::client_common::ResponsesApiRequest;
use crate::client_common::create_reasoning_param_for_request;
use crate::client_common::create_text_param_for_request;
use crate::config::Config;
use crate::default_client::CodexHttpClient;
use crate::default_client::create_client;
use crate::error::CodexErr;
use crate::error::ConnectionFailedError;
@@ -82,7 +81,7 @@ pub struct ModelClient {
config: Arc<Config>,
auth_manager: Option<Arc<AuthManager>>,
otel_event_manager: OtelEventManager,
client: CodexHttpClient,
client: reqwest::Client,
provider: ModelProviderInfo,
conversation_id: ConversationId,
effort: Option<ReasoningEffortConfig>,
@@ -134,14 +133,6 @@ impl ModelClient {
self.stream_with_task_kind(prompt, TaskKind::Regular).await
}
pub fn config(&self) -> Arc<Config> {
Arc::clone(&self.config)
}
pub fn provider(&self) -> &ModelProviderInfo {
&self.provider
}
pub(crate) async fn stream_with_task_kind(
&self,
prompt: &Prompt,
@@ -309,7 +300,6 @@ impl ModelClient {
"POST to {}: {:?}",
self.provider.get_full_url(&auth),
serde_json::to_string(payload_json)
.unwrap_or("<unable to serialize payload>".to_string())
);
let mut req_builder = self
@@ -345,6 +335,13 @@ impl ModelClient {
.headers()
.get("cf-ray")
.map(|v| v.to_str().unwrap_or_default().to_string());
debug!(
"Response status: {}, cf-ray: {:?}, version: {:?}",
resp.status(),
request_id,
resp.version()
);
}
match res {

View File

@@ -8,7 +8,6 @@ use crate::AuthManager;
use crate::client_common::REVIEW_PROMPT;
use crate::function_tool::FunctionCallError;
use crate::mcp::auth::McpAuthStatusEntry;
use crate::mcp_connection_manager::DEFAULT_STARTUP_TIMEOUT;
use crate::parse_command::parse_command;
use crate::parse_turn_item;
use crate::response_processing::process_items;
@@ -60,6 +59,7 @@ use crate::config::Config;
use crate::config_types::McpServerTransportConfig;
use crate::config_types::ShellEnvironmentPolicy;
use crate::conversation_history::ConversationHistory;
use crate::conversation_history::prefetch_tokenizer_in_background;
use crate::environment_context::EnvironmentContext;
use crate::error::CodexErr;
use crate::error::Result as CodexResult;
@@ -88,7 +88,6 @@ use crate::protocol::Op;
use crate::protocol::RateLimitSnapshot;
use crate::protocol::ReviewDecision;
use crate::protocol::ReviewOutputEvent;
use crate::protocol::SandboxCommandAssessment;
use crate::protocol::SandboxPolicy;
use crate::protocol::SessionConfiguredEvent;
use crate::protocol::StreamErrorEvent;
@@ -161,6 +160,8 @@ impl Codex {
conversation_history: InitialHistory,
session_source: SessionSource,
) -> CodexResult<CodexSpawnOk> {
// Start loading the tokenizer in the background so we don't block later.
prefetch_tokenizer_in_background();
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let (tx_event, rx_event) = async_channel::unbounded();
@@ -570,6 +571,9 @@ impl Session {
// Dispatch the SessionConfiguredEvent first and then report any errors.
// If resuming, include converted initial messages in the payload so UIs can render them immediately.
let initial_messages = initial_history.get_event_msgs();
sess.record_initial_history(initial_history)
.await
.map_err(anyhow::Error::new)?;
let events = std::iter::once(Event {
id: INITIAL_SUBMIT_ID.to_owned(),
@@ -588,9 +592,6 @@ impl Session {
sess.send_event_raw(event).await;
}
// record_initial_history can emit events. We record only after the SessionConfiguredEvent is emitted.
sess.record_initial_history(initial_history).await;
Ok(sess)
}
@@ -605,13 +606,16 @@ impl Session {
format!("auto-compact-{id}")
}
async fn record_initial_history(&self, conversation_history: InitialHistory) {
async fn record_initial_history(
&self,
conversation_history: InitialHistory,
) -> CodexResult<()> {
let turn_context = self.new_turn(SessionSettingsUpdate::default()).await;
match conversation_history {
InitialHistory::New => {
// Build and record initial items (user instructions + environment context)
let items = self.build_initial_context(&turn_context);
self.record_conversation_items(&turn_context, &items).await;
self.record_conversation_items(&items).await?;
}
InitialHistory::Resumed(_) | InitialHistory::Forked(_) => {
let rollout_items = conversation_history.get_rollout_items();
@@ -619,9 +623,9 @@ impl Session {
// Always add response items to conversation history
let reconstructed_history =
self.reconstruct_history_from_rollout(&turn_context, &rollout_items);
self.reconstruct_history_from_rollout(&turn_context, &rollout_items)?;
if !reconstructed_history.is_empty() {
self.record_into_history(&reconstructed_history).await;
self.record_into_history(&reconstructed_history).await?;
}
// If persisting, persist all rollout items as-is (recorder filters)
@@ -630,6 +634,7 @@ impl Session {
}
}
}
Ok(())
}
pub(crate) async fn update_settings(&self, updates: SessionSettingsUpdate) {
@@ -758,32 +763,6 @@ impl Session {
}
}
pub(crate) async fn assess_sandbox_command(
&self,
turn_context: &TurnContext,
call_id: &str,
command: &[String],
failure_message: Option<&str>,
) -> Option<SandboxCommandAssessment> {
let config = turn_context.client.config();
let provider = turn_context.client.provider().clone();
let auth_manager = Arc::clone(&self.services.auth_manager);
let otel = self.services.otel_event_manager.clone();
crate::sandboxing::assessment::assess_command(
config,
provider,
auth_manager,
&otel,
self.conversation_id,
call_id,
command,
&turn_context.sandbox_policy,
&turn_context.cwd,
failure_message,
)
.await
}
/// Emit an exec approval request event and await the user's decision.
///
/// The request is keyed by `sub_id`/`call_id` so matching responses are delivered
@@ -796,7 +775,6 @@ impl Session {
command: Vec<String>,
cwd: PathBuf,
reason: Option<String>,
risk: Option<SandboxCommandAssessment>,
) -> ReviewDecision {
let sub_id = turn_context.sub_id.clone();
// Add the tx_approve callback to the map before sending the request.
@@ -822,7 +800,6 @@ impl Session {
command,
cwd,
reason,
risk,
parsed_cmd,
});
self.send_event(turn_context, event).await;
@@ -890,24 +867,23 @@ impl Session {
/// persist these response items to rollout.
pub(crate) async fn record_conversation_items(
&self,
turn_context: &TurnContext,
items: &[ResponseItem],
) {
self.record_into_history(items).await;
) -> CodexResult<()> {
self.record_into_history(items).await?;
self.persist_rollout_response_items(items).await;
self.send_raw_response_items(turn_context, items).await;
Ok(())
}
fn reconstruct_history_from_rollout(
&self,
turn_context: &TurnContext,
rollout_items: &[RolloutItem],
) -> Vec<ResponseItem> {
) -> CodexResult<Vec<ResponseItem>> {
let mut history = ConversationHistory::new();
for item in rollout_items {
match item {
RolloutItem::ResponseItem(response_item) => {
history.record_items(std::iter::once(response_item));
history.record_items(std::iter::once(response_item))?;
}
RolloutItem::Compacted(compacted) => {
let snapshot = history.get_history();
@@ -922,13 +898,14 @@ impl Session {
_ => {}
}
}
history.get_history()
Ok(history.get_history())
}
/// Append ResponseItems to the in-memory conversation history only.
async fn record_into_history(&self, items: &[ResponseItem]) {
async fn record_into_history(&self, items: &[ResponseItem]) -> CodexResult<()> {
let mut state = self.state.lock().await;
state.record_items(items.iter());
state.record_items(items.iter())?;
Ok(())
}
async fn replace_history(&self, items: Vec<ResponseItem>) {
@@ -945,13 +922,6 @@ impl Session {
self.persist_rollout_items(&rollout_items).await;
}
async fn send_raw_response_items(&self, turn_context: &TurnContext, items: &[ResponseItem]) {
for item in items {
self.send_event(turn_context, EventMsg::RawResponseItem(item.clone()))
.await;
}
}
pub(crate) fn build_initial_context(&self, turn_context: &TurnContext) -> Vec<ResponseItem> {
let mut items = Vec::<ResponseItem>::with_capacity(2);
if let Some(user_instructions) = turn_context.user_instructions.as_deref() {
@@ -1044,11 +1014,11 @@ impl Session {
&self,
turn_context: &TurnContext,
response_input: &ResponseInputItem,
) {
) -> CodexResult<()> {
let response_item: ResponseItem = response_input.clone().into();
// Add to conversation history and persist response item to rollout
self.record_conversation_items(turn_context, std::slice::from_ref(&response_item))
.await;
self.record_conversation_items(std::slice::from_ref(&response_item))
.await?;
// Derive user message events and persist only UserMessage to rollout
let turn_item = parse_turn_item(&response_item);
@@ -1057,6 +1027,7 @@ impl Session {
self.emit_turn_item_started_completed(turn_context, item, false)
.await;
}
Ok(())
}
/// Helper that emits a BackgroundEvent with the given message. This keeps
@@ -1237,10 +1208,15 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
if let Err(items) = sess.inject_input(items).await {
if let Some(env_item) = sess
.build_environment_update_item(previous_context.as_ref(), &current_context)
&& let Err(err) = sess
.record_conversation_items(std::slice::from_ref(&env_item))
.await
{
sess.record_conversation_items(
&current_context,
std::slice::from_ref(&env_item),
sess.send_event(
current_context.as_ref(),
EventMsg::Error(ErrorEvent {
message: err.to_string(),
}),
)
.await;
}
@@ -1555,9 +1531,9 @@ pub(crate) async fn run_task(
input: Vec<UserInput>,
task_kind: TaskKind,
cancellation_token: CancellationToken,
) -> Option<String> {
) -> CodexResult<Option<String>> {
if input.is_empty() {
return None;
return Ok(None);
}
let event = EventMsg::TaskStarted(TaskStartedEvent {
model_context_window: turn_context.client.get_model_context_window(),
@@ -1574,11 +1550,11 @@ pub(crate) async fn run_task(
if is_review_mode {
// Seed review threads with environment context so the model knows the working directory.
review_thread_history
.record_items(sess.build_initial_context(turn_context.as_ref()).iter());
review_thread_history.record_items(std::iter::once(&initial_input_for_turn.into()));
.record_items(sess.build_initial_context(turn_context.as_ref()).iter())?;
review_thread_history.record_items(std::iter::once(&initial_input_for_turn.into()))?;
} else {
sess.record_input_and_rollout_usermsg(turn_context.as_ref(), &initial_input_for_turn)
.await;
.await?;
}
let mut last_agent_message: Option<String> = None;
@@ -1610,12 +1586,11 @@ pub(crate) async fn run_task(
// represents an append-only log without duplicates.
let turn_input: Vec<ResponseItem> = if is_review_mode {
if !pending_input.is_empty() {
review_thread_history.record_items(&pending_input);
review_thread_history.record_items(&pending_input)?;
}
review_thread_history.get_history()
} else {
sess.record_conversation_items(&turn_context, &pending_input)
.await;
sess.record_conversation_items(&pending_input).await?;
sess.history_snapshot().await
};
@@ -1662,9 +1637,8 @@ pub(crate) async fn run_task(
is_review_mode,
&mut review_thread_history,
&sess,
&turn_context,
)
.await;
.await?;
if token_limit_reached {
if auto_compact_recently_attempted {
@@ -1681,7 +1655,8 @@ pub(crate) async fn run_task(
break;
}
auto_compact_recently_attempted = true;
compact::run_inline_auto_compact_task(sess.clone(), turn_context.clone()).await;
compact::run_inline_auto_compact_task(sess.clone(), turn_context.clone())
.await?;
continue;
}
@@ -1706,14 +1681,13 @@ pub(crate) async fn run_task(
Err(CodexErr::TurnAborted {
dangling_artifacts: processed_items,
}) => {
let _ = process_items(
process_items(
processed_items,
is_review_mode,
&mut review_thread_history,
&sess,
&turn_context,
)
.await;
.await?;
// Aborted turn is reported via a different event.
break;
}
@@ -1742,10 +1716,10 @@ pub(crate) async fn run_task(
Arc::clone(&turn_context),
last_agent_message.as_deref().map(parse_review_output_event),
)
.await;
.await?;
}
last_agent_message
Ok(last_agent_message)
}
/// Parse the review output; when not valid JSON, build a structured
@@ -2184,7 +2158,7 @@ pub(crate) async fn exit_review_mode(
session: Arc<Session>,
turn_context: Arc<TurnContext>,
review_output: Option<ReviewOutputEvent>,
) {
) -> CodexResult<()> {
let event = EventMsg::ExitedReviewMode(ExitedReviewModeEvent {
review_output: review_output.clone(),
});
@@ -2222,15 +2196,13 @@ pub(crate) async fn exit_review_mode(
}
session
.record_conversation_items(
&turn_context,
&[ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText { text: user_message }],
}],
)
.await;
.record_conversation_items(&[ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText { text: user_message }],
}])
.await?;
Ok(())
}
fn mcp_init_error_display(
@@ -2252,24 +2224,12 @@ fn mcp_init_error_display(
// That means that the user has to specify a personal access token either via bearer_token_env_var or http_headers.
// https://github.com/github/github-mcp-server/issues/921#issuecomment-3221026448
format!(
"GitHub MCP does not support OAuth. Log in by adding a personal access token (https://github.com/settings/personal-access-tokens) to your environment and config.toml:\n[mcp_servers.{server_name}]\nbearer_token_env_var = CODEX_GITHUB_PERSONAL_ACCESS_TOKEN"
"GitHub MCP does not support OAuth. Log in by adding `bearer_token_env_var = CODEX_GITHUB_PAT` in the `mcp_servers.{server_name}` section of your config.toml"
)
} else if is_mcp_client_auth_required_error(err) {
format!(
"The {server_name} MCP server is not logged in. Run `codex mcp login {server_name}`."
)
} else if is_mcp_client_startup_timeout_error(err) {
let startup_timeout_secs = match entry {
Some(entry) => match entry.config.startup_timeout_sec {
Some(timeout) => timeout,
None => DEFAULT_STARTUP_TIMEOUT,
},
None => DEFAULT_STARTUP_TIMEOUT,
}
.as_secs();
format!(
"MCP client for `{server_name}` timed out after {startup_timeout_secs} seconds. Add or adjust `startup_timeout_sec` in your config.toml:\n[mcp_servers.{server_name}]\nstartup_timeout_sec = XX"
)
} else {
format!("MCP client for `{server_name}` failed to start: {err:#}")
}
@@ -2280,12 +2240,6 @@ fn is_mcp_client_auth_required_error(error: &anyhow::Error) -> bool {
error.to_string().contains("Auth required")
}
fn is_mcp_client_startup_timeout_error(error: &anyhow::Error) -> bool {
let error_message = error.to_string();
error_message.contains("request timed out")
|| error_message.contains("timed out handshaking with MCP server")
}
#[cfg(test)]
pub(crate) use tests::make_session_and_context;
@@ -2296,6 +2250,7 @@ mod tests {
use crate::config::ConfigToml;
use crate::config_types::McpServerConfig;
use crate::config_types::McpServerTransportConfig;
use crate::error::Result as CodexResult;
use crate::exec::ExecToolCallOutput;
use crate::mcp::auth::McpAuthStatusEntry;
use crate::tools::format_exec_output_str;
@@ -2336,9 +2291,12 @@ mod tests {
#[test]
fn reconstruct_history_matches_live_compactions() {
let (session, turn_context) = make_session_and_context();
let (rollout_items, expected) = sample_rollout(&session, &turn_context);
let (rollout_items, expected) =
sample_rollout(&session, &turn_context).expect("sample rollout");
let reconstructed = session.reconstruct_history_from_rollout(&turn_context, &rollout_items);
let reconstructed = session
.reconstruct_history_from_rollout(&turn_context, &rollout_items)
.expect("reconstruct history");
assert_eq!(expected, reconstructed);
}
@@ -2346,15 +2304,19 @@ mod tests {
#[test]
fn record_initial_history_reconstructs_resumed_transcript() {
let (session, turn_context) = make_session_and_context();
let (rollout_items, expected) = sample_rollout(&session, &turn_context);
let (rollout_items, expected) =
sample_rollout(&session, &turn_context).expect("sample rollout");
tokio_test::block_on(session.record_initial_history(InitialHistory::Resumed(
ResumedHistory {
conversation_id: ConversationId::default(),
history: rollout_items,
rollout_path: PathBuf::from("/tmp/resume.jsonl"),
},
)));
tokio_test::block_on(async {
session
.record_initial_history(InitialHistory::Resumed(ResumedHistory {
conversation_id: ConversationId::default(),
history: rollout_items,
rollout_path: PathBuf::from("/tmp/resume.jsonl"),
}))
.await
.expect("record resumed history");
});
let actual = tokio_test::block_on(async { session.state.lock().await.history_snapshot() });
assert_eq!(expected, actual);
@@ -2363,9 +2325,15 @@ mod tests {
#[test]
fn record_initial_history_reconstructs_forked_transcript() {
let (session, turn_context) = make_session_and_context();
let (rollout_items, expected) = sample_rollout(&session, &turn_context);
let (rollout_items, expected) =
sample_rollout(&session, &turn_context).expect("sample rollout");
tokio_test::block_on(session.record_initial_history(InitialHistory::Forked(rollout_items)));
tokio_test::block_on(async {
session
.record_initial_history(InitialHistory::Forked(rollout_items))
.await
.expect("record forked history");
});
let actual = tokio_test::block_on(async { session.state.lock().await.history_snapshot() });
assert_eq!(expected, actual);
@@ -2725,10 +2693,10 @@ mod tests {
_ctx: Arc<TurnContext>,
_input: Vec<UserInput>,
cancellation_token: CancellationToken,
) -> Option<String> {
) -> CodexResult<Option<String>> {
if self.listen_to_cancellation_token {
cancellation_token.cancelled().await;
return None;
return Ok(None);
}
loop {
sleep(Duration::from_secs(60)).await;
@@ -2737,7 +2705,7 @@ mod tests {
async fn abort(&self, session: Arc<SessionTaskContext>, ctx: Arc<TurnContext>) {
if let TaskKind::Review = self.kind {
exit_review_mode(session.clone_session(), ctx, None).await;
let _ = exit_review_mode(session.clone_session(), ctx, None).await;
}
}
}
@@ -2824,19 +2792,13 @@ mod tests {
EventMsg::ExitedReviewMode(ev) => assert!(ev.review_output.is_none()),
other => panic!("unexpected first event: {other:?}"),
}
loop {
let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
.await
.expect("timeout waiting for next event")
.expect("event");
match evt.msg {
EventMsg::RawResponseItem(_) => continue,
EventMsg::TurnAborted(e) => {
assert_eq!(TurnAbortReason::Interrupted, e.reason);
break;
}
other => panic!("unexpected second event: {other:?}"),
}
let second = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
.await
.expect("timeout waiting for second event")
.expect("second event");
match second.msg {
EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason),
other => panic!("unexpected second event: {other:?}"),
}
let history = sess.history_snapshot().await;
@@ -2899,7 +2861,7 @@ mod tests {
fn sample_rollout(
session: &Session,
turn_context: &TurnContext,
) -> (Vec<RolloutItem>, Vec<ResponseItem>) {
) -> CodexResult<(Vec<RolloutItem>, Vec<ResponseItem>)> {
let mut rollout_items = Vec::new();
let mut live_history = ConversationHistory::new();
@@ -2907,7 +2869,7 @@ mod tests {
for item in &initial_context {
rollout_items.push(RolloutItem::ResponseItem(item.clone()));
}
live_history.record_items(initial_context.iter());
live_history.record_items(initial_context.iter())?;
let user1 = ResponseItem::Message {
id: None,
@@ -2916,7 +2878,7 @@ mod tests {
text: "first user".to_string(),
}],
};
live_history.record_items(std::iter::once(&user1));
live_history.record_items(std::iter::once(&user1))?;
rollout_items.push(RolloutItem::ResponseItem(user1.clone()));
let assistant1 = ResponseItem::Message {
@@ -2926,7 +2888,7 @@ mod tests {
text: "assistant reply one".to_string(),
}],
};
live_history.record_items(std::iter::once(&assistant1));
live_history.record_items(std::iter::once(&assistant1))?;
rollout_items.push(RolloutItem::ResponseItem(assistant1.clone()));
let summary1 = "summary one";
@@ -2949,7 +2911,7 @@ mod tests {
text: "second user".to_string(),
}],
};
live_history.record_items(std::iter::once(&user2));
live_history.record_items(std::iter::once(&user2))?;
rollout_items.push(RolloutItem::ResponseItem(user2.clone()));
let assistant2 = ResponseItem::Message {
@@ -2959,7 +2921,7 @@ mod tests {
text: "assistant reply two".to_string(),
}],
};
live_history.record_items(std::iter::once(&assistant2));
live_history.record_items(std::iter::once(&assistant2))?;
rollout_items.push(RolloutItem::ResponseItem(assistant2.clone()));
let summary2 = "summary two";
@@ -2982,7 +2944,7 @@ mod tests {
text: "third user".to_string(),
}],
};
live_history.record_items(std::iter::once(&user3));
live_history.record_items(std::iter::once(&user3))?;
rollout_items.push(RolloutItem::ResponseItem(user3.clone()));
let assistant3 = ResponseItem::Message {
@@ -2992,10 +2954,10 @@ mod tests {
text: "assistant reply three".to_string(),
}],
};
live_history.record_items(std::iter::once(&assistant3));
live_history.record_items(std::iter::once(&assistant3))?;
rollout_items.push(RolloutItem::ResponseItem(assistant3.clone()));
(rollout_items, live_history.get_history())
Ok((rollout_items, live_history.get_history()))
}
#[tokio::test]
@@ -3149,7 +3111,7 @@ mod tests {
let display = mcp_init_error_display(server_name, Some(&entry), &err);
let expected = format!(
"GitHub MCP does not support OAuth. Log in by adding a personal access token (https://github.com/settings/personal-access-tokens) to your environment and config.toml:\n[mcp_servers.{server_name}]\nbearer_token_env_var = CODEX_GITHUB_PERSONAL_ACCESS_TOKEN"
"GitHub MCP does not support OAuth. Log in by adding `bearer_token_env_var = CODEX_GITHUB_PAT` in the `mcp_servers.{server_name}` section of your config.toml"
);
assert_eq!(expected, display);
@@ -3196,17 +3158,4 @@ mod tests {
assert_eq!(expected, display);
}
#[test]
fn mcp_init_error_display_includes_startup_timeout_hint() {
let server_name = "slow";
let err = anyhow::anyhow!("request timed out");
let display = mcp_init_error_display(server_name, None, &err);
assert_eq!(
"MCP client for `slow` timed out after 10 seconds. Add or adjust `startup_timeout_sec` in your config.toml:\n[mcp_servers.slow]\nstartup_timeout_sec = XX",
display
);
}
}

View File

@@ -39,35 +39,35 @@ struct HistoryBridgeTemplate<'a> {
pub(crate) async fn run_inline_auto_compact_task(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
) {
) -> CodexResult<()> {
let input = vec![UserInput::Text {
text: SUMMARIZATION_PROMPT.to_string(),
}];
run_compact_task_inner(sess, turn_context, input).await;
run_compact_task_inner(sess, turn_context, input).await
}
pub(crate) async fn run_compact_task(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
input: Vec<UserInput>,
) -> Option<String> {
) -> CodexResult<Option<String>> {
let start_event = EventMsg::TaskStarted(TaskStartedEvent {
model_context_window: turn_context.client.get_model_context_window(),
});
sess.send_event(&turn_context, start_event).await;
run_compact_task_inner(sess.clone(), turn_context, input).await;
None
run_compact_task_inner(sess.clone(), turn_context, input).await?;
Ok(None)
}
async fn run_compact_task_inner(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
input: Vec<UserInput>,
) {
) -> CodexResult<()> {
let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input);
let mut history = sess.clone_history().await;
history.record_items(&[initial_input_for_turn.into()]);
history.record_items(&[initial_input_for_turn.into()])?;
let mut truncated_count = 0usize;
@@ -106,7 +106,7 @@ async fn run_compact_task_inner(
break;
}
Err(CodexErr::Interrupted) => {
return;
return Ok(());
}
Err(e @ CodexErr::ContextWindowExceeded) => {
if turn_input.len() > 1 {
@@ -124,7 +124,7 @@ async fn run_compact_task_inner(
message: e.to_string(),
});
sess.send_event(&turn_context, event).await;
return;
return Ok(());
}
Err(e) => {
if retries < max_retries {
@@ -142,7 +142,7 @@ async fn run_compact_task_inner(
message: e.to_string(),
});
sess.send_event(&turn_context, event).await;
return;
return Ok(());
}
}
}
@@ -164,6 +164,7 @@ async fn run_compact_task_inner(
message: "Compact task completed".to_string(),
});
sess.send_event(&turn_context, event).await;
Ok(())
}
pub fn content_items_to_text(content: &[ContentItem]) -> Option<String> {
@@ -200,20 +201,7 @@ pub(crate) fn build_compacted_history(
user_messages: &[String],
summary_text: &str,
) -> Vec<ResponseItem> {
build_compacted_history_with_limit(
initial_context,
user_messages,
summary_text,
COMPACT_USER_MESSAGE_MAX_TOKENS * 4,
)
}
fn build_compacted_history_with_limit(
mut history: Vec<ResponseItem>,
user_messages: &[String],
summary_text: &str,
max_bytes: usize,
) -> Vec<ResponseItem> {
let mut history = initial_context;
let mut user_messages_text = if user_messages.is_empty() {
"(none)".to_string()
} else {
@@ -221,6 +209,7 @@ fn build_compacted_history_with_limit(
};
// Truncate the concatenated prior user messages so the bridge message
// stays well under the context window (approx. 4 bytes/token).
let max_bytes = COMPACT_USER_MESSAGE_MAX_TOKENS * 4;
if user_messages_text.len() > max_bytes {
user_messages_text = truncate_middle(&user_messages_text, max_bytes).0;
}
@@ -264,7 +253,8 @@ async fn drain_to_completed(
};
match event {
Ok(ResponseEvent::OutputItemDone(item)) => {
sess.record_into_history(std::slice::from_ref(&item)).await;
sess.record_into_history(std::slice::from_ref(&item))
.await?;
}
Ok(ResponseEvent::RateLimits(snapshot)) => {
sess.update_rate_limits(turn_context, snapshot).await;
@@ -373,16 +363,11 @@ mod tests {
#[test]
fn build_compacted_history_truncates_overlong_user_messages() {
// Use a small truncation limit so the test remains fast while still validating
// that oversized user content is truncated.
let max_bytes = 128;
let big = "X".repeat(max_bytes + 50);
let history = super::build_compacted_history_with_limit(
Vec::new(),
std::slice::from_ref(&big),
"SUMMARY",
max_bytes,
);
// Prepare a very large prior user message so the aggregated
// `user_messages_text` exceeds the truncation threshold used by
// `build_compacted_history` (80k bytes).
let big = "X".repeat(200_000);
let history = build_compacted_history(Vec::new(), std::slice::from_ref(&big), "SUMMARY");
// Expect exactly one bridge message added to history (plus any initial context we provided, which is none).
assert_eq!(history.len(), 1);

View File

@@ -223,9 +223,6 @@ pub struct Config {
pub tools_web_search_request: bool,
/// When `true`, run a model-based assessment for commands denied by the sandbox.
pub experimental_sandbox_command_assessment: bool,
pub use_experimental_streamable_shell_tool: bool,
/// If set to `true`, used only the experimental unified exec tool.
@@ -961,7 +958,6 @@ pub struct ConfigToml {
pub experimental_use_unified_exec_tool: Option<bool>,
pub experimental_use_rmcp_client: Option<bool>,
pub experimental_use_freeform_apply_patch: Option<bool>,
pub experimental_sandbox_command_assessment: Option<bool>,
}
impl From<ConfigToml> for UserSavedConfig {
@@ -1027,11 +1023,9 @@ impl ConfigToml {
fn derive_sandbox_policy(
&self,
sandbox_mode_override: Option<SandboxMode>,
profile_sandbox_mode: Option<SandboxMode>,
resolved_cwd: &Path,
) -> SandboxPolicy {
let resolved_sandbox_mode = sandbox_mode_override
.or(profile_sandbox_mode)
.or(self.sandbox_mode)
.or_else(|| {
// if no sandbox_mode is set, but user has marked directory as trusted, use WorkspaceWrite
@@ -1124,7 +1118,6 @@ pub struct ConfigOverrides {
pub include_view_image_tool: Option<bool>,
pub show_raw_agent_reasoning: Option<bool>,
pub tools_web_search_request: Option<bool>,
pub experimental_sandbox_command_assessment: Option<bool>,
/// Additional directories that should be treated as writable roots for this session.
pub additional_writable_roots: Vec<PathBuf>,
}
@@ -1154,7 +1147,6 @@ impl Config {
include_view_image_tool: include_view_image_tool_override,
show_raw_agent_reasoning,
tools_web_search_request: override_tools_web_search_request,
experimental_sandbox_command_assessment: sandbox_command_assessment_override,
additional_writable_roots,
} = overrides;
@@ -1180,7 +1172,6 @@ impl Config {
include_apply_patch_tool: include_apply_patch_tool_override,
include_view_image_tool: include_view_image_tool_override,
web_search_request: override_tools_web_search_request,
experimental_sandbox_command_assessment: sandbox_command_assessment_override,
};
let features = Features::from_config(&cfg, &config_profile, feature_overrides);
@@ -1221,8 +1212,7 @@ impl Config {
.get_active_project(&resolved_cwd)
.unwrap_or(ProjectConfig { trust_level: None });
let mut sandbox_policy =
cfg.derive_sandbox_policy(sandbox_mode, config_profile.sandbox_mode, &resolved_cwd);
let mut sandbox_policy = cfg.derive_sandbox_policy(sandbox_mode, &resolved_cwd);
if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy {
for path in additional_writable_roots {
if !writable_roots.iter().any(|existing| existing == &path) {
@@ -1245,8 +1235,8 @@ impl Config {
.is_some()
|| config_profile.approval_policy.is_some()
|| cfg.approval_policy.is_some()
// TODO(#3034): profile.sandbox_mode is not implemented
|| sandbox_mode.is_some()
|| config_profile.sandbox_mode.is_some()
|| cfg.sandbox_mode.is_some();
let mut model_providers = built_in_model_providers();
@@ -1279,8 +1269,6 @@ impl Config {
let use_experimental_streamable_shell_tool = features.enabled(Feature::StreamableShell);
let use_experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec);
let use_experimental_use_rmcp_client = features.enabled(Feature::RmcpClient);
let experimental_sandbox_command_assessment =
features.enabled(Feature::SandboxCommandAssessment);
let forced_chatgpt_workspace_id =
cfg.forced_chatgpt_workspace_id.as_ref().and_then(|value| {
@@ -1402,7 +1390,6 @@ impl Config {
forced_login_method,
include_apply_patch_tool: include_apply_patch_tool_flag,
tools_web_search_request,
experimental_sandbox_command_assessment,
use_experimental_streamable_shell_tool,
use_experimental_unified_exec_tool,
use_experimental_use_rmcp_client,
@@ -1606,11 +1593,8 @@ network_access = false # This should be ignored.
let sandbox_mode_override = None;
assert_eq!(
SandboxPolicy::DangerFullAccess,
sandbox_full_access_cfg.derive_sandbox_policy(
sandbox_mode_override,
None,
&PathBuf::from("/tmp/test")
)
sandbox_full_access_cfg
.derive_sandbox_policy(sandbox_mode_override, &PathBuf::from("/tmp/test"))
);
let sandbox_read_only = r#"
@@ -1625,11 +1609,8 @@ network_access = true # This should be ignored.
let sandbox_mode_override = None;
assert_eq!(
SandboxPolicy::ReadOnly,
sandbox_read_only_cfg.derive_sandbox_policy(
sandbox_mode_override,
None,
&PathBuf::from("/tmp/test")
)
sandbox_read_only_cfg
.derive_sandbox_policy(sandbox_mode_override, &PathBuf::from("/tmp/test"))
);
let sandbox_workspace_write = r#"
@@ -1653,11 +1634,8 @@ exclude_slash_tmp = true
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
},
sandbox_workspace_write_cfg.derive_sandbox_policy(
sandbox_mode_override,
None,
&PathBuf::from("/tmp/test")
)
sandbox_workspace_write_cfg
.derive_sandbox_policy(sandbox_mode_override, &PathBuf::from("/tmp/test"))
);
let sandbox_workspace_write = r#"
@@ -1684,11 +1662,8 @@ trust_level = "trusted"
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
},
sandbox_workspace_write_cfg.derive_sandbox_policy(
sandbox_mode_override,
None,
&PathBuf::from("/tmp/test")
)
sandbox_workspace_write_cfg
.derive_sandbox_policy(sandbox_mode_override, &PathBuf::from("/tmp/test"))
);
}
@@ -1780,75 +1755,6 @@ trust_level = "trusted"
Ok(())
}
#[test]
fn profile_sandbox_mode_overrides_base() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let mut profiles = HashMap::new();
profiles.insert(
"work".to_string(),
ConfigProfile {
sandbox_mode: Some(SandboxMode::DangerFullAccess),
..Default::default()
},
);
let cfg = ConfigToml {
profiles,
profile: Some("work".to_string()),
sandbox_mode: Some(SandboxMode::ReadOnly),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.path().to_path_buf(),
)?;
assert!(matches!(
config.sandbox_policy,
SandboxPolicy::DangerFullAccess
));
assert!(config.did_user_set_custom_approval_policy_or_sandbox_mode);
Ok(())
}
#[test]
fn cli_override_takes_precedence_over_profile_sandbox_mode() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let mut profiles = HashMap::new();
profiles.insert(
"work".to_string(),
ConfigProfile {
sandbox_mode: Some(SandboxMode::DangerFullAccess),
..Default::default()
},
);
let cfg = ConfigToml {
profiles,
profile: Some("work".to_string()),
..Default::default()
};
let overrides = ConfigOverrides {
sandbox_mode: Some(SandboxMode::WorkspaceWrite),
..Default::default()
};
let config = Config::load_from_base_config_with_overrides(
cfg,
overrides,
codex_home.path().to_path_buf(),
)?;
assert!(matches!(
config.sandbox_policy,
SandboxPolicy::WorkspaceWrite { .. }
));
Ok(())
}
#[test]
fn feature_table_overrides_legacy_flags() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
@@ -2967,7 +2873,6 @@ model_verbosity = "high"
forced_login_method: None,
include_apply_patch_tool: false,
tools_web_search_request: false,
experimental_sandbox_command_assessment: false,
use_experimental_streamable_shell_tool: false,
use_experimental_unified_exec_tool: false,
use_experimental_use_rmcp_client: false,
@@ -3036,7 +2941,6 @@ model_verbosity = "high"
forced_login_method: None,
include_apply_patch_tool: false,
tools_web_search_request: false,
experimental_sandbox_command_assessment: false,
use_experimental_streamable_shell_tool: false,
use_experimental_unified_exec_tool: false,
use_experimental_use_rmcp_client: false,
@@ -3120,7 +3024,6 @@ model_verbosity = "high"
forced_login_method: None,
include_apply_patch_tool: false,
tools_web_search_request: false,
experimental_sandbox_command_assessment: false,
use_experimental_streamable_shell_tool: false,
use_experimental_unified_exec_tool: false,
use_experimental_use_rmcp_client: false,
@@ -3190,7 +3093,6 @@ model_verbosity = "high"
forced_login_method: None,
include_apply_patch_tool: false,
tools_web_search_request: false,
experimental_sandbox_command_assessment: false,
use_experimental_streamable_shell_tool: false,
use_experimental_unified_exec_tool: false,
use_experimental_use_rmcp_client: false,

View File

@@ -4,7 +4,6 @@ use std::path::PathBuf;
use crate::protocol::AskForApproval;
use codex_protocol::config_types::ReasoningEffort;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::Verbosity;
/// Collection of common configuration options that a user can define as a unit
@@ -16,7 +15,6 @@ pub struct ConfigProfile {
/// [`ModelProviderInfo`] to use.
pub model_provider: Option<String>,
pub approval_policy: Option<AskForApproval>,
pub sandbox_mode: Option<SandboxMode>,
pub model_reasoning_effort: Option<ReasoningEffort>,
pub model_reasoning_summary: Option<ReasoningSummary>,
pub model_verbosity: Option<Verbosity>,
@@ -28,7 +26,6 @@ pub struct ConfigProfile {
pub experimental_use_exec_command_tool: Option<bool>,
pub experimental_use_rmcp_client: Option<bool>,
pub experimental_use_freeform_apply_patch: Option<bool>,
pub experimental_sandbox_command_assessment: Option<bool>,
pub tools_web_search: Option<bool>,
pub tools_view_image: Option<bool>,
/// Optional feature toggles scoped to this profile.

View File

@@ -1,11 +1,20 @@
use std::sync::Arc;
use std::sync::OnceLock;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
use codex_utils_tokenizer::Tokenizer;
use tokio::task;
use tracing::error;
static TOKENIZER: OnceLock<Option<Arc<Tokenizer>>> = OnceLock::new();
use crate::error::CodexErr;
/// Transcript of conversation history
#[derive(Debug, Clone, Default)]
#[derive(Debug, Clone)]
pub(crate) struct ConversationHistory {
/// The oldest items are at the beginning of the vector.
items: Vec<ResponseItem>,
@@ -16,7 +25,7 @@ impl ConversationHistory {
pub(crate) fn new() -> Self {
Self {
items: Vec::new(),
token_info: TokenUsageInfo::new_or_append(&None, &None, None),
token_info: None,
}
}
@@ -34,7 +43,7 @@ impl ConversationHistory {
}
/// `items` is ordered from oldest to newest.
pub(crate) fn record_items<I>(&mut self, items: I)
pub(crate) fn record_items<I>(&mut self, items: I) -> Result<(), CodexErr>
where
I: IntoIterator,
I::Item: std::ops::Deref<Target = ResponseItem>,
@@ -43,9 +52,10 @@ impl ConversationHistory {
if !is_api_message(&item) {
continue;
}
self.validate_input(&item)?;
self.items.push(item.clone());
}
Ok(())
}
pub(crate) fn get_history(&mut self) -> Vec<ResponseItem> {
@@ -81,6 +91,65 @@ impl ConversationHistory {
self.items.clone()
}
fn validate_input(&self, item: &ResponseItem) -> Result<(), CodexErr> {
match item {
ResponseItem::Message { content, .. } => {
self.validate_input_content_item(content)?;
Ok(())
}
ResponseItem::FunctionCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::Reasoning { .. }
| ResponseItem::WebSearchCall { .. } => Ok(()),
ResponseItem::Other => Err(CodexErr::InvalidInput(format!("invalid input: {item:?}"))),
}
}
fn validate_input_content_item(
&self,
content: &[codex_protocol::models::ContentItem],
) -> Result<(), CodexErr> {
let Some(info) = &self.token_info else {
return Ok(());
};
// this will intentionally not check the context for the first turn before getting this information.
// it's acceptable tradeoff.
let Some(context_window) = info.model_context_window else {
return Ok(());
};
let tokenizer = match shared_tokenizer() {
Some(t) => t,
None => return Ok(()),
};
let mut input_tokens: i64 = 0;
for item in content {
match item {
codex_protocol::models::ContentItem::InputText { text } => {
input_tokens += tokenizer.count(text);
}
codex_protocol::models::ContentItem::InputImage { .. } => {
// no validation currently
}
codex_protocol::models::ContentItem::OutputText { .. } => {
// no validation currently
}
}
}
let prior_total = info.last_token_usage.total_tokens;
let combined_tokens = prior_total.saturating_add(input_tokens);
let threshold = context_window * 95 / 100;
if combined_tokens > threshold {
return Err(CodexErr::InvalidInput("input too large".to_string()));
}
Ok(())
}
fn ensure_call_outputs_present(&mut self) {
// Collect synthetic outputs to insert immediately after their calls.
// Store the insertion position (index of call) alongside the item so
@@ -343,6 +412,36 @@ fn error_or_panic(message: String) {
}
}
fn shared_tokenizer() -> Option<Arc<Tokenizer>> {
TOKENIZER.get().and_then(|opt| opt.as_ref().map(Arc::clone))
}
/// Kick off background initialization of the shared tokenizer without blocking the caller.
pub(crate) fn prefetch_tokenizer_in_background() {
if TOKENIZER.get().is_some() {
return;
}
// Spawn a background task to initialize the tokenizer. Use spawn_blocking in case
// initialization performs CPU-heavy work or file I/O.
tokio::spawn(async {
let result = task::spawn_blocking(Tokenizer::try_default).await;
match result {
Ok(Ok(tokenizer)) => {
let _ = TOKENIZER.set(Some(Arc::new(tokenizer)));
}
Ok(Err(error)) => {
error!("failed to create tokenizer: {error}");
let _ = TOKENIZER.set(None);
}
Err(join_error) => {
error!("failed to join tokenizer init task: {join_error}");
let _ = TOKENIZER.set(None);
}
}
});
}
/// Anything that is not a system message or "reasoning" message is considered
/// an API message.
fn is_api_message(message: &ResponseItem) -> bool {
@@ -381,7 +480,7 @@ mod tests {
fn create_history_with_items(items: Vec<ResponseItem>) -> ConversationHistory {
let mut h = ConversationHistory::new();
h.record_items(items.iter());
h.record_items(items.iter()).unwrap();
h
}
@@ -397,7 +496,7 @@ mod tests {
#[test]
fn filters_non_api_messages() {
let mut h = ConversationHistory::default();
let mut h = ConversationHistory::new();
// System message is not an API message; Other is ignored.
let system = ResponseItem::Message {
id: None,
@@ -406,12 +505,12 @@ mod tests {
text: "ignored".to_string(),
}],
};
h.record_items([&system, &ResponseItem::Other]);
h.record_items([&system, &ResponseItem::Other]).unwrap();
// User and assistant should be retained.
let u = user_msg("hi");
let a = assistant_msg("hello");
h.record_items([&u, &a]);
h.record_items([&u, &a]).unwrap();
let items = h.contents();
assert_eq!(

View File

@@ -1,13 +1,5 @@
use crate::spawn::CODEX_SANDBOX_ENV_VAR;
use http::Error as HttpError;
use reqwest::IntoUrl;
use reqwest::Method;
use reqwest::Response;
use reqwest::header::HeaderName;
use reqwest::header::HeaderValue;
use serde::Serialize;
use std::collections::HashMap;
use std::fmt::Display;
use std::sync::LazyLock;
use std::sync::Mutex;
use std::sync::OnceLock;
@@ -30,130 +22,6 @@ use std::sync::OnceLock;
pub static USER_AGENT_SUFFIX: LazyLock<Mutex<Option<String>>> = LazyLock::new(|| Mutex::new(None));
pub const DEFAULT_ORIGINATOR: &str = "codex_cli_rs";
pub const CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR: &str = "CODEX_INTERNAL_ORIGINATOR_OVERRIDE";
#[derive(Clone, Debug)]
pub struct CodexHttpClient {
inner: reqwest::Client,
}
impl CodexHttpClient {
fn new(inner: reqwest::Client) -> Self {
Self { inner }
}
pub fn get<U>(&self, url: U) -> CodexRequestBuilder
where
U: IntoUrl,
{
self.request(Method::GET, url)
}
pub fn post<U>(&self, url: U) -> CodexRequestBuilder
where
U: IntoUrl,
{
self.request(Method::POST, url)
}
pub fn request<U>(&self, method: Method, url: U) -> CodexRequestBuilder
where
U: IntoUrl,
{
let url_str = url.as_str().to_string();
CodexRequestBuilder::new(self.inner.request(method.clone(), url), method, url_str)
}
}
#[must_use = "requests are not sent unless `send` is awaited"]
#[derive(Debug)]
pub struct CodexRequestBuilder {
builder: reqwest::RequestBuilder,
method: Method,
url: String,
}
impl CodexRequestBuilder {
fn new(builder: reqwest::RequestBuilder, method: Method, url: String) -> Self {
Self {
builder,
method,
url,
}
}
fn map(self, f: impl FnOnce(reqwest::RequestBuilder) -> reqwest::RequestBuilder) -> Self {
Self {
builder: f(self.builder),
method: self.method,
url: self.url,
}
}
pub fn header<K, V>(self, key: K, value: V) -> Self
where
HeaderName: TryFrom<K>,
<HeaderName as TryFrom<K>>::Error: Into<HttpError>,
HeaderValue: TryFrom<V>,
<HeaderValue as TryFrom<V>>::Error: Into<HttpError>,
{
self.map(|builder| builder.header(key, value))
}
pub fn bearer_auth<T>(self, token: T) -> Self
where
T: Display,
{
self.map(|builder| builder.bearer_auth(token))
}
pub fn json<T>(self, value: &T) -> Self
where
T: ?Sized + Serialize,
{
self.map(|builder| builder.json(value))
}
pub async fn send(self) -> Result<Response, reqwest::Error> {
match self.builder.send().await {
Ok(response) => {
let request_ids = Self::extract_request_ids(&response);
tracing::debug!(
method = %self.method,
url = %self.url,
status = %response.status(),
request_ids = ?request_ids,
version = ?response.version(),
"Request completed"
);
Ok(response)
}
Err(error) => {
let status = error.status();
tracing::debug!(
method = %self.method,
url = %self.url,
status = status.map(|s| s.as_u16()),
error = %error,
"Request failed"
);
Err(error)
}
}
}
fn extract_request_ids(response: &Response) -> HashMap<String, String> {
["cf-ray", "x-request-id", "x-oai-request-id"]
.iter()
.filter_map(|&name| {
let header_name = HeaderName::from_static(name);
let value = response.headers().get(header_name)?;
let value = value.to_str().ok()?.to_owned();
Some((name.to_owned(), value))
})
.collect()
}
}
#[derive(Debug, Clone)]
pub struct Originator {
pub value: String,
@@ -256,8 +124,8 @@ fn sanitize_user_agent(candidate: String, fallback: &str) -> String {
}
}
/// Create an HTTP client with default `originator` and `User-Agent` headers set.
pub fn create_client() -> CodexHttpClient {
/// Create a reqwest client with default `originator` and `User-Agent` headers set.
pub fn create_client() -> reqwest::Client {
use reqwest::header::HeaderMap;
let mut headers = HeaderMap::new();
@@ -272,8 +140,7 @@ pub fn create_client() -> CodexHttpClient {
builder = builder.no_proxy();
}
let inner = builder.build().unwrap_or_else(|_| reqwest::Client::new());
CodexHttpClient::new(inner)
builder.build().unwrap_or_else(|_| reqwest::Client::new())
}
fn is_sandboxed() -> bool {

View File

@@ -55,7 +55,7 @@ pub enum SandboxErr {
#[derive(Error, Debug)]
pub enum CodexErr {
// todo(aibrahim): git rid of this error carrying the dangling artifacts
#[error("turn aborted. Something went wrong? Hit `/feedback` to report the issue.")]
#[error("turn aborted")]
TurnAborted {
dangling_artifacts: Vec<ProcessedResponseItem>,
},
@@ -91,7 +91,7 @@ pub enum CodexErr {
/// Returned by run_command_stream when the user pressed CtrlC (SIGINT). Session uses this to
/// surface a polite FunctionCallOutput back to the model instead of crashing the CLI.
#[error("interrupted (Ctrl-C). Something went wrong? Hit `/feedback` to report the issue.")]
#[error("interrupted (Ctrl-C)")]
Interrupted,
/// Unexpected HTTP status code.
@@ -158,6 +158,9 @@ pub enum CodexErr {
#[error("{0}")]
EnvVar(EnvVarError),
#[error("invalid input: {0}")]
InvalidInput(String),
}
impl From<CancelErr> for CodexErr {

View File

@@ -39,8 +39,6 @@ pub enum Feature {
ViewImageTool,
/// Allow the model to request web searches.
WebSearchRequest,
/// Enable the model-based risk assessments for sandboxed commands.
SandboxCommandAssessment,
}
impl Feature {
@@ -75,7 +73,6 @@ pub struct FeatureOverrides {
pub include_apply_patch_tool: Option<bool>,
pub include_view_image_tool: Option<bool>,
pub web_search_request: Option<bool>,
pub experimental_sandbox_command_assessment: Option<bool>,
}
impl FeatureOverrides {
@@ -140,7 +137,6 @@ impl Features {
let mut features = Features::with_defaults();
let base_legacy = LegacyFeatureToggles {
experimental_sandbox_command_assessment: cfg.experimental_sandbox_command_assessment,
experimental_use_freeform_apply_patch: cfg.experimental_use_freeform_apply_patch,
experimental_use_exec_command_tool: cfg.experimental_use_exec_command_tool,
experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool,
@@ -158,8 +154,6 @@ impl Features {
let profile_legacy = LegacyFeatureToggles {
include_apply_patch_tool: config_profile.include_apply_patch_tool,
include_view_image_tool: config_profile.include_view_image_tool,
experimental_sandbox_command_assessment: config_profile
.experimental_sandbox_command_assessment,
experimental_use_freeform_apply_patch: config_profile
.experimental_use_freeform_apply_patch,
experimental_use_exec_command_tool: config_profile.experimental_use_exec_command_tool,
@@ -242,10 +236,4 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: false,
},
FeatureSpec {
id: Feature::SandboxCommandAssessment,
key: "experimental_sandbox_command_assessment",
stage: Stage::Experimental,
default_enabled: false,
},
];

View File

@@ -9,10 +9,6 @@ struct Alias {
}
const ALIASES: &[Alias] = &[
Alias {
legacy_key: "experimental_sandbox_command_assessment",
feature: Feature::SandboxCommandAssessment,
},
Alias {
legacy_key: "experimental_use_unified_exec_tool",
feature: Feature::UnifiedExec,
@@ -57,7 +53,6 @@ pub(crate) fn feature_for_key(key: &str) -> Option<Feature> {
pub struct LegacyFeatureToggles {
pub include_apply_patch_tool: Option<bool>,
pub include_view_image_tool: Option<bool>,
pub experimental_sandbox_command_assessment: Option<bool>,
pub experimental_use_freeform_apply_patch: Option<bool>,
pub experimental_use_exec_command_tool: Option<bool>,
pub experimental_use_unified_exec_tool: Option<bool>,
@@ -74,12 +69,6 @@ impl LegacyFeatureToggles {
self.include_apply_patch_tool,
"include_apply_patch_tool",
);
set_if_some(
features,
Feature::SandboxCommandAssessment,
self.experimental_sandbox_command_assessment,
"experimental_sandbox_command_assessment",
);
set_if_some(
features,
Feature::ApplyPatchFreeform,

View File

@@ -49,7 +49,7 @@ const MCP_TOOL_NAME_DELIMITER: &str = "__";
const MAX_TOOL_NAME_LENGTH: usize = 64;
/// Default timeout for initializing MCP server & initially listing tools.
pub const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
/// Default timeout for individual tool calls.
const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(60);

View File

@@ -6,8 +6,6 @@
//! key. These override or extend the defaults at runtime.
use crate::CodexAuth;
use crate::default_client::CodexHttpClient;
use crate::default_client::CodexRequestBuilder;
use codex_app_server_protocol::AuthMode;
use serde::Deserialize;
use serde::Serialize;
@@ -97,7 +95,7 @@ pub struct ModelProviderInfo {
impl ModelProviderInfo {
/// Construct a `POST` RequestBuilder for the given URL using the provided
/// [`CodexHttpClient`] applying:
/// reqwest Client applying:
/// • provider-specific headers (static + env based)
/// • Bearer auth header when an API key is available.
/// • Auth token for OAuth.
@@ -106,9 +104,9 @@ impl ModelProviderInfo {
/// one produced by [`ModelProviderInfo::api_key`].
pub async fn create_request_builder<'a>(
&'a self,
client: &'a CodexHttpClient,
client: &'a reqwest::Client,
auth: &Option<CodexAuth>,
) -> crate::error::Result<CodexRequestBuilder> {
) -> crate::error::Result<reqwest::RequestBuilder> {
let effective_auth = if let Some(secret_key) = &self.experimental_bearer_token {
Some(CodexAuth::from_api_key(secret_key))
} else {
@@ -189,9 +187,9 @@ impl ModelProviderInfo {
}
/// Apply provider-specific HTTP headers (both static and environment-based)
/// onto an existing [`CodexRequestBuilder`] and return the updated
/// onto an existing `reqwest::RequestBuilder` and return the updated
/// builder.
fn apply_http_headers(&self, mut builder: CodexRequestBuilder) -> CodexRequestBuilder {
fn apply_http_headers(&self, mut builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some(extra) = &self.http_headers {
for (k, v) in extra {
builder = builder.header(k, v);

View File

@@ -1,6 +1,6 @@
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::conversation_history::ConversationHistory;
use crate::error::Result as CodexResult;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
@@ -14,8 +14,7 @@ pub(crate) async fn process_items(
is_review_mode: bool,
review_thread_history: &mut ConversationHistory,
sess: &Session,
turn_context: &TurnContext,
) -> (Vec<ResponseInputItem>, Vec<ResponseItem>) {
) -> CodexResult<(Vec<ResponseInputItem>, Vec<ResponseItem>)> {
let mut items_to_record_in_conversation_history = Vec::<ResponseItem>::new();
let mut responses = Vec::<ResponseInputItem>::new();
for processed_response_item in processed_items {
@@ -104,11 +103,11 @@ pub(crate) async fn process_items(
// Only attempt to take the lock if there is something to record.
if !items_to_record_in_conversation_history.is_empty() {
if is_review_mode {
review_thread_history.record_items(items_to_record_in_conversation_history.iter());
review_thread_history.record_items(items_to_record_in_conversation_history.iter())?;
} else {
sess.record_conversation_items(turn_context, &items_to_record_in_conversation_history)
.await;
sess.record_conversation_items(&items_to_record_in_conversation_history)
.await?;
}
}
(responses, items_to_record_in_conversation_history)
Ok((responses, items_to_record_in_conversation_history))
}

View File

@@ -50,7 +50,6 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
| EventMsg::AgentReasoningDelta(_)
| EventMsg::AgentReasoningRawContentDelta(_)
| EventMsg::AgentReasoningSectionBreak(_)
| EventMsg::RawResponseItem(_)
| EventMsg::SessionConfigured(_)
| EventMsg::McpToolCallBegin(_)
| EventMsg::McpToolCallEnd(_)

View File

@@ -1,275 +0,0 @@
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use crate::AuthManager;
use crate::ModelProviderInfo;
use crate::client::ModelClient;
use crate::client_common::Prompt;
use crate::client_common::ResponseEvent;
use crate::config::Config;
use crate::protocol::SandboxPolicy;
use askama::Template;
use codex_otel::otel_event_manager::OtelEventManager;
use codex_protocol::ConversationId;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::SandboxCommandAssessment;
use futures::StreamExt;
use serde_json::json;
use tokio::time::timeout;
use tracing::warn;
const SANDBOX_ASSESSMENT_TIMEOUT: Duration = Duration::from_secs(5);
const SANDBOX_RISK_CATEGORY_VALUES: &[&str] = &[
"data_deletion",
"data_exfiltration",
"privilege_escalation",
"system_modification",
"network_access",
"resource_exhaustion",
"compliance",
];
#[derive(Template)]
#[template(path = "sandboxing/assessment_prompt.md", escape = "none")]
struct SandboxAssessmentPromptTemplate<'a> {
platform: &'a str,
sandbox_policy: &'a str,
filesystem_roots: Option<&'a str>,
working_directory: &'a str,
command_argv: &'a str,
command_joined: &'a str,
sandbox_failure_message: Option<&'a str>,
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn assess_command(
config: Arc<Config>,
provider: ModelProviderInfo,
auth_manager: Arc<AuthManager>,
parent_otel: &OtelEventManager,
conversation_id: ConversationId,
call_id: &str,
command: &[String],
sandbox_policy: &SandboxPolicy,
cwd: &Path,
failure_message: Option<&str>,
) -> Option<SandboxCommandAssessment> {
if !config.experimental_sandbox_command_assessment || command.is_empty() {
return None;
}
let command_json = serde_json::to_string(command).unwrap_or_else(|_| "[]".to_string());
let command_joined =
shlex::try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" "));
let failure = failure_message
.map(str::trim)
.filter(|msg| !msg.is_empty())
.map(str::to_string);
let cwd_str = cwd.to_string_lossy().to_string();
let sandbox_summary = summarize_sandbox_policy(sandbox_policy);
let mut roots = sandbox_roots_for_prompt(sandbox_policy, cwd);
roots.sort();
roots.dedup();
let platform = std::env::consts::OS;
let roots_formatted = roots.iter().map(|root| root.to_string_lossy().to_string());
let filesystem_roots = match roots_formatted.collect::<Vec<_>>() {
collected if collected.is_empty() => None,
collected => Some(collected.join(", ")),
};
let prompt_template = SandboxAssessmentPromptTemplate {
platform,
sandbox_policy: sandbox_summary.as_str(),
filesystem_roots: filesystem_roots.as_deref(),
working_directory: cwd_str.as_str(),
command_argv: command_json.as_str(),
command_joined: command_joined.as_str(),
sandbox_failure_message: failure.as_deref(),
};
let rendered_prompt = match prompt_template.render() {
Ok(rendered) => rendered,
Err(err) => {
warn!("failed to render sandbox assessment prompt: {err}");
return None;
}
};
let (system_prompt_section, user_prompt_section) = match rendered_prompt.split_once("\n---\n") {
Some(split) => split,
None => {
warn!("rendered sandbox assessment prompt missing separator");
return None;
}
};
let system_prompt = system_prompt_section
.strip_prefix("System Prompt:\n")
.unwrap_or(system_prompt_section)
.trim()
.to_string();
let user_prompt = user_prompt_section
.strip_prefix("User Prompt:\n")
.unwrap_or(user_prompt_section)
.trim()
.to_string();
let prompt = Prompt {
input: vec![ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText { text: user_prompt }],
}],
tools: Vec::new(),
parallel_tool_calls: false,
base_instructions_override: Some(system_prompt),
output_schema: Some(sandbox_assessment_schema()),
};
let child_otel =
parent_otel.with_model(config.model.as_str(), config.model_family.slug.as_str());
let client = ModelClient::new(
Arc::clone(&config),
Some(auth_manager),
child_otel,
provider,
config.model_reasoning_effort,
config.model_reasoning_summary,
conversation_id,
);
let start = Instant::now();
let assessment_result = timeout(SANDBOX_ASSESSMENT_TIMEOUT, async move {
let mut stream = client.stream(&prompt).await?;
let mut last_json: Option<String> = None;
while let Some(event) = stream.next().await {
match event {
Ok(ResponseEvent::OutputItemDone(item)) => {
if let Some(text) = response_item_text(&item) {
last_json = Some(text);
}
}
Ok(ResponseEvent::RateLimits(_)) => {}
Ok(ResponseEvent::Completed { .. }) => break,
Ok(_) => continue,
Err(err) => return Err(err),
}
}
Ok(last_json)
})
.await;
let duration = start.elapsed();
parent_otel.sandbox_assessment_latency(call_id, duration);
match assessment_result {
Ok(Ok(Some(raw))) => match serde_json::from_str::<SandboxCommandAssessment>(raw.trim()) {
Ok(assessment) => {
parent_otel.sandbox_assessment(
call_id,
"success",
Some(assessment.risk_level),
&assessment.risk_categories,
duration,
);
return Some(assessment);
}
Err(err) => {
warn!("failed to parse sandbox assessment JSON: {err}");
parent_otel.sandbox_assessment(call_id, "parse_error", None, &[], duration);
}
},
Ok(Ok(None)) => {
warn!("sandbox assessment response did not include any message");
parent_otel.sandbox_assessment(call_id, "no_output", None, &[], duration);
}
Ok(Err(err)) => {
warn!("sandbox assessment failed: {err}");
parent_otel.sandbox_assessment(call_id, "model_error", None, &[], duration);
}
Err(_) => {
warn!("sandbox assessment timed out");
parent_otel.sandbox_assessment(call_id, "timeout", None, &[], duration);
}
}
None
}
fn summarize_sandbox_policy(policy: &SandboxPolicy) -> String {
match policy {
SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(),
SandboxPolicy::ReadOnly => "read-only".to_string(),
SandboxPolicy::WorkspaceWrite { network_access, .. } => {
let network = if *network_access {
"network"
} else {
"no-network"
};
format!("workspace-write (network_access={network})")
}
}
}
fn sandbox_roots_for_prompt(policy: &SandboxPolicy, cwd: &Path) -> Vec<PathBuf> {
let mut roots = vec![cwd.to_path_buf()];
if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = policy {
roots.extend(writable_roots.iter().cloned());
}
roots
}
fn sandbox_assessment_schema() -> serde_json::Value {
json!({
"type": "object",
"required": ["description", "risk_level", "risk_categories"],
"properties": {
"description": {
"type": "string",
"minLength": 1,
"maxLength": 500
},
"risk_level": {
"type": "string",
"enum": ["low", "medium", "high"]
},
"risk_categories": {
"type": "array",
"items": {
"type": "string",
"enum": SANDBOX_RISK_CATEGORY_VALUES
}
}
},
"additionalProperties": false
})
}
fn response_item_text(item: &ResponseItem) -> Option<String> {
match item {
ResponseItem::Message { content, .. } => {
let mut buffers: Vec<&str> = Vec::new();
for segment in content {
match segment {
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
if !text.is_empty() {
buffers.push(text);
}
}
ContentItem::InputImage { .. } => {}
}
}
if buffers.is_empty() {
None
} else {
Some(buffers.join("\n"))
}
}
ResponseItem::FunctionCallOutput { output, .. } => Some(output.content.clone()),
_ => None,
}
}

View File

@@ -5,9 +5,6 @@ Build platform wrappers and produce ExecEnv for execution. Owns lowlevel
sandbox placement and transformation of portable CommandSpec into a
readytospawn environment.
*/
pub mod assessment;
use crate::exec::ExecToolCallOutput;
use crate::exec::SandboxType;
use crate::exec::StdoutStream;

View File

@@ -4,6 +4,7 @@ use codex_protocol::models::ResponseItem;
use crate::codex::SessionConfiguration;
use crate::conversation_history::ConversationHistory;
use crate::error::CodexErr;
use crate::protocol::RateLimitSnapshot;
use crate::protocol::TokenUsage;
use crate::protocol::TokenUsageInfo;
@@ -26,12 +27,13 @@ impl SessionState {
}
// History helpers
pub(crate) fn record_items<I>(&mut self, items: I)
pub(crate) fn record_items<I>(&mut self, items: I) -> Result<(), CodexErr>
where
I: IntoIterator,
I::Item: std::ops::Deref<Target = ResponseItem>,
{
self.history.record_items(items)
self.history.record_items(items)?;
Ok(())
}
pub(crate) fn history_snapshot(&mut self) -> Vec<ResponseItem> {
@@ -66,7 +68,14 @@ impl SessionState {
pub(crate) fn token_info_and_rate_limits(
&self,
) -> (Option<TokenUsageInfo>, Option<RateLimitSnapshot>) {
(self.token_info(), self.latest_rate_limits.clone())
let info = self.token_info().and_then(|info| {
if info.total_token_usage.is_zero() && info.last_token_usage.is_zero() {
None
} else {
Some(info)
}
});
(info, self.latest_rate_limits.clone())
}
pub(crate) fn set_token_usage_full(&mut self, context_window: i64) {

View File

@@ -5,6 +5,7 @@ use tokio_util::sync::CancellationToken;
use crate::codex::TurnContext;
use crate::codex::compact;
use crate::error::Result as CodexResult;
use crate::state::TaskKind;
use codex_protocol::user_input::UserInput;
@@ -26,7 +27,7 @@ impl SessionTask for CompactTask {
ctx: Arc<TurnContext>,
input: Vec<UserInput>,
_cancellation_token: CancellationToken,
) -> Option<String> {
) -> CodexResult<Option<String>> {
compact::run_compact_task(session.clone_session(), ctx, input).await
}
}

View File

@@ -15,6 +15,8 @@ use tracing::warn;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::error::Result as CodexResult;
use crate::protocol::ErrorEvent;
use crate::protocol::EventMsg;
use crate::protocol::TaskCompleteEvent;
use crate::protocol::TurnAbortReason;
@@ -56,7 +58,7 @@ pub(crate) trait SessionTask: Send + Sync + 'static {
ctx: Arc<TurnContext>,
input: Vec<UserInput>,
cancellation_token: CancellationToken,
) -> Option<String>;
) -> CodexResult<Option<String>>;
async fn abort(&self, session: Arc<SessionTaskContext>, ctx: Arc<TurnContext>) {
let _ = (session, ctx);
@@ -86,7 +88,7 @@ impl Session {
let task_cancellation_token = cancellation_token.child_token();
tokio::spawn(async move {
let ctx_for_finish = Arc::clone(&ctx);
let last_agent_message = task_for_run
let run_result = task_for_run
.run(
Arc::clone(&session_ctx),
ctx,
@@ -98,8 +100,21 @@ impl Session {
if !task_cancellation_token.is_cancelled() {
// Emit completion uniformly from spawn site so all tasks share the same lifecycle.
let sess = session_ctx.clone_session();
sess.on_task_finished(ctx_for_finish, last_agent_message)
.await;
match run_result {
Ok(last_agent_message) => {
sess.on_task_finished(ctx_for_finish, last_agent_message)
.await;
}
Err(err) => {
let message = err.to_string();
sess.send_event(
ctx_for_finish.as_ref(),
EventMsg::Error(ErrorEvent { message }),
)
.await;
sess.on_task_finished(ctx_for_finish, None).await;
}
}
}
done_clone.notify_waiters();
})

View File

@@ -5,6 +5,7 @@ use tokio_util::sync::CancellationToken;
use crate::codex::TurnContext;
use crate::codex::run_task;
use crate::error::Result as CodexResult;
use crate::state::TaskKind;
use codex_protocol::user_input::UserInput;
@@ -26,7 +27,7 @@ impl SessionTask for RegularTask {
ctx: Arc<TurnContext>,
input: Vec<UserInput>,
cancellation_token: CancellationToken,
) -> Option<String> {
) -> CodexResult<Option<String>> {
let sess = session.clone_session();
run_task(sess, ctx, input, TaskKind::Regular, cancellation_token).await
}

View File

@@ -6,6 +6,7 @@ use tokio_util::sync::CancellationToken;
use crate::codex::TurnContext;
use crate::codex::exit_review_mode;
use crate::codex::run_task;
use crate::error::Result as CodexResult;
use crate::state::TaskKind;
use codex_protocol::user_input::UserInput;
@@ -27,12 +28,12 @@ impl SessionTask for ReviewTask {
ctx: Arc<TurnContext>,
input: Vec<UserInput>,
cancellation_token: CancellationToken,
) -> Option<String> {
) -> CodexResult<Option<String>> {
let sess = session.clone_session();
run_task(sess, ctx, input, TaskKind::Review, cancellation_token).await
}
async fn abort(&self, session: Arc<SessionTaskContext>, ctx: Arc<TurnContext>) {
exit_review_mode(session.clone_session(), ctx, None).await;
let _ = exit_review_mode(session.clone_session(), ctx, None).await;
}
}

View File

@@ -7,11 +7,9 @@ retry without sandbox on denial (no reapproval thanks to caching).
*/
use crate::error::CodexErr;
use crate::error::SandboxErr;
use crate::error::get_error_message_ui;
use crate::exec::ExecToolCallOutput;
use crate::sandboxing::SandboxManager;
use crate::tools::sandboxing::ApprovalCtx;
use crate::tools::sandboxing::ProvidesSandboxRetryData;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
@@ -40,7 +38,6 @@ impl ToolOrchestrator {
) -> Result<Out, ToolError>
where
T: ToolRuntime<Rq, Out>,
Rq: ProvidesSandboxRetryData,
{
let otel = turn_ctx.client.get_otel_event_manager();
let otel_tn = &tool_ctx.tool_name;
@@ -59,7 +56,6 @@ impl ToolOrchestrator {
turn: turn_ctx,
call_id: &tool_ctx.call_id,
retry_reason: None,
risk: None,
};
let decision = tool.start_approval_async(req, approval_ctx).await;
@@ -111,33 +107,12 @@ impl ToolOrchestrator {
// Ask for approval before retrying without sandbox.
if !tool.should_bypass_approval(approval_policy, already_approved) {
let mut risk = None;
if let Some(metadata) = req.sandbox_retry_data() {
let err = SandboxErr::Denied {
output: output.clone(),
};
let friendly = get_error_message_ui(&CodexErr::Sandbox(err));
let failure_summary = format!("failed in sandbox: {friendly}");
risk = tool_ctx
.session
.assess_sandbox_command(
turn_ctx,
&tool_ctx.call_id,
&metadata.command,
Some(failure_summary.as_str()),
)
.await;
}
let reason_msg = build_denial_reason_from_output(output.as_ref());
let approval_ctx = ApprovalCtx {
session: tool_ctx.session,
turn: turn_ctx,
call_id: &tool_ctx.call_id,
retry_reason: Some(reason_msg),
risk,
};
let decision = tool.start_approval_async(req, approval_ctx).await;

View File

@@ -10,9 +10,7 @@ use crate::sandboxing::CommandSpec;
use crate::sandboxing::execute_env;
use crate::tools::sandboxing::Approvable;
use crate::tools::sandboxing::ApprovalCtx;
use crate::tools::sandboxing::ProvidesSandboxRetryData;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::SandboxRetryData;
use crate::tools::sandboxing::Sandboxable;
use crate::tools::sandboxing::SandboxablePreference;
use crate::tools::sandboxing::ToolCtx;
@@ -34,12 +32,6 @@ pub struct ApplyPatchRequest {
pub codex_exe: Option<PathBuf>,
}
impl ProvidesSandboxRetryData for ApplyPatchRequest {
fn sandbox_retry_data(&self) -> Option<SandboxRetryData> {
None
}
}
#[derive(Default)]
pub struct ApplyPatchRuntime;
@@ -114,10 +106,9 @@ impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
let call_id = ctx.call_id.to_string();
let cwd = req.cwd.clone();
let retry_reason = ctx.retry_reason.clone();
let risk = ctx.risk.clone();
let user_explicitly_approved = req.user_explicitly_approved;
Box::pin(async move {
with_cached_approval(&session.services, key, move || async move {
with_cached_approval(&session.services, key, || async move {
if let Some(reason) = retry_reason {
session
.request_command_approval(
@@ -126,7 +117,6 @@ impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
vec!["apply_patch".to_string()],
cwd,
Some(reason),
risk,
)
.await
} else if user_explicitly_approved {

View File

@@ -12,9 +12,7 @@ use crate::sandboxing::execute_env;
use crate::tools::runtimes::build_command_spec;
use crate::tools::sandboxing::Approvable;
use crate::tools::sandboxing::ApprovalCtx;
use crate::tools::sandboxing::ProvidesSandboxRetryData;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::SandboxRetryData;
use crate::tools::sandboxing::Sandboxable;
use crate::tools::sandboxing::SandboxablePreference;
use crate::tools::sandboxing::ToolCtx;
@@ -36,15 +34,6 @@ pub struct ShellRequest {
pub justification: Option<String>,
}
impl ProvidesSandboxRetryData for ShellRequest {
fn sandbox_retry_data(&self) -> Option<SandboxRetryData> {
Some(SandboxRetryData {
command: self.command.clone(),
cwd: self.cwd.clone(),
})
}
}
#[derive(Default)]
pub struct ShellRuntime;
@@ -101,14 +90,13 @@ impl Approvable<ShellRequest> for ShellRuntime {
.retry_reason
.clone()
.or_else(|| req.justification.clone());
let risk = ctx.risk.clone();
let session = ctx.session;
let turn = ctx.turn;
let call_id = ctx.call_id.to_string();
Box::pin(async move {
with_cached_approval(&session.services, key, move || async move {
with_cached_approval(&session.services, key, || async move {
session
.request_command_approval(turn, call_id, command, cwd, reason, risk)
.request_command_approval(turn, call_id, command, cwd, reason)
.await
})
.await

View File

@@ -9,9 +9,7 @@ use crate::error::SandboxErr;
use crate::tools::runtimes::build_command_spec;
use crate::tools::sandboxing::Approvable;
use crate::tools::sandboxing::ApprovalCtx;
use crate::tools::sandboxing::ProvidesSandboxRetryData;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::SandboxRetryData;
use crate::tools::sandboxing::Sandboxable;
use crate::tools::sandboxing::SandboxablePreference;
use crate::tools::sandboxing::ToolCtx;
@@ -33,15 +31,6 @@ pub struct UnifiedExecRequest {
pub env: HashMap<String, String>,
}
impl ProvidesSandboxRetryData for UnifiedExecRequest {
fn sandbox_retry_data(&self) -> Option<SandboxRetryData> {
Some(SandboxRetryData {
command: self.command.clone(),
cwd: self.cwd.clone(),
})
}
}
#[derive(serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)]
pub struct UnifiedExecApprovalKey {
pub command: Vec<String>,
@@ -96,11 +85,10 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
let command = req.command.clone();
let cwd = req.cwd.clone();
let reason = ctx.retry_reason.clone();
let risk = ctx.risk.clone();
Box::pin(async move {
with_cached_approval(&session.services, key, || async move {
session
.request_command_approval(turn, call_id, command, cwd, reason, risk)
.request_command_approval(turn, call_id, command, cwd, reason)
.await
})
.await

View File

@@ -7,7 +7,6 @@
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::error::CodexErr;
use crate::protocol::SandboxCommandAssessment;
use crate::protocol::SandboxPolicy;
use crate::sandboxing::CommandSpec;
use crate::sandboxing::SandboxManager;
@@ -19,7 +18,6 @@ use std::collections::HashMap;
use std::fmt::Debug;
use std::hash::Hash;
use std::path::Path;
use std::path::PathBuf;
use futures::Future;
use futures::future::BoxFuture;
@@ -83,7 +81,6 @@ pub(crate) struct ApprovalCtx<'a> {
pub turn: &'a TurnContext,
pub call_id: &'a str,
pub retry_reason: Option<String>,
pub risk: Option<SandboxCommandAssessment>,
}
pub(crate) trait Approvable<Req> {
@@ -159,17 +156,6 @@ pub(crate) struct ToolCtx<'a> {
pub tool_name: String,
}
/// Captures the command metadata needed to re-run a tool request without sandboxing.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct SandboxRetryData {
pub command: Vec<String>,
pub cwd: PathBuf,
}
pub(crate) trait ProvidesSandboxRetryData {
fn sandbox_retry_data(&self) -> Option<SandboxRetryData>;
}
#[derive(Debug)]
pub(crate) enum ToolError {
Rejected(String),

View File

@@ -1,27 +0,0 @@
You are a security analyst evaluating shell commands that were blocked by a sandbox. Given the provided metadata, summarize the command's likely intent and assess the risk. Return strictly valid JSON with the keys:
- description (concise summary, at most two sentences)
- risk_level ("low", "medium", or "high")
- risk_categories (optional array of zero or more category strings)
Risk level examples:
- low: read-only inspections, listing files, printing configuration
- medium: modifying project files, installing dependencies, fetching artifacts from trusted sources
- high: deleting or overwriting data, exfiltrating secrets, escalating privileges, or disabling security controls
Recognized risk_categories: data_deletion, data_exfiltration, privilege_escalation, system_modification, network_access, resource_exhaustion, compliance.
Use multiple categories when appropriate.
If information is insufficient, choose the most cautious risk level supported by the evidence.
Respond with JSON only, without markdown code fences or extra commentary.
---
Command metadata:
Platform: {{ platform }}
Sandbox policy: {{ sandbox_policy }}
{% if let Some(roots) = filesystem_roots %}
Filesystem roots: {{ roots }}
{% endif %}
Working directory: {{ working_directory }}
Command argv: {{ command_argv }}
Command (joined): {{ command_joined }}
{% if let Some(message) = sandbox_failure_message %}
Sandbox failure message: {{ message }}
{% endif %}

View File

@@ -279,11 +279,6 @@ async fn auto_compact_runs_after_token_limit_hit() {
ev_completed_with_tokens("r2", 330_000),
]);
let sse3 = sse(vec![
ev_assistant_message("m3", AUTO_SUMMARY_TEXT),
ev_completed_with_tokens("r3", 200),
]);
let first_matcher = |req: &wiremock::Request| {
let body = std::str::from_utf8(&req.body).unwrap_or("");
body.contains(FIRST_AUTO_MSG)
@@ -300,12 +295,6 @@ async fn auto_compact_runs_after_token_limit_hit() {
};
mount_sse_once_match(&server, second_matcher, sse2).await;
let third_matcher = |req: &wiremock::Request| {
let body = std::str::from_utf8(&req.body).unwrap_or("");
body.contains("You have exceeded the maximum number of tokens")
};
mount_sse_once_match(&server, third_matcher, sse3).await;
let model_provider = ModelProviderInfo {
base_url: Some(format!("{}/v1", server.uri())),
..built_in_model_providers()["openai"].clone()
@@ -342,69 +331,28 @@ async fn auto_compact_runs_after_token_limit_hit() {
.await
.unwrap();
let error_event = wait_for_event(&codex, |ev| matches!(ev, EventMsg::Error(_))).await;
let EventMsg::Error(error_event) = error_event else {
unreachable!("wait_for_event returned unexpected payload");
};
assert_eq!(error_event.message, "invalid input: input too large");
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
// wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
let requests = server.received_requests().await.unwrap();
assert!(
requests.len() >= 3,
"auto compact should add at least a third request, got {}",
requests.len()
assert_eq!(
requests.len(),
2,
"auto compact should reject oversize prompts before issuing another request"
);
let is_auto_compact = |req: &wiremock::Request| {
let saw_compact_prompt = requests.iter().any(|req| {
std::str::from_utf8(&req.body)
.unwrap_or("")
.contains("You have exceeded the maximum number of tokens")
};
let auto_compact_count = requests.iter().filter(|req| is_auto_compact(req)).count();
assert_eq!(
auto_compact_count, 1,
"expected exactly one auto compact request"
);
let auto_compact_index = requests
.iter()
.enumerate()
.find_map(|(idx, req)| is_auto_compact(req).then_some(idx))
.expect("auto compact request missing");
assert_eq!(
auto_compact_index, 2,
"auto compact should add a third request"
);
let body_first = requests[0].body_json::<serde_json::Value>().unwrap();
let body3 = requests[auto_compact_index]
.body_json::<serde_json::Value>()
.unwrap();
let instructions = body3
.get("instructions")
.and_then(|v| v.as_str())
.unwrap_or_default();
let baseline_instructions = body_first
.get("instructions")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
assert_eq!(
instructions, baseline_instructions,
"auto compact should keep the standard developer instructions",
);
let input3 = body3.get("input").and_then(|v| v.as_array()).unwrap();
let last3 = input3
.last()
.expect("auto compact request should append a user message");
assert_eq!(last3.get("type").and_then(|v| v.as_str()), Some("message"));
assert_eq!(last3.get("role").and_then(|v| v.as_str()), Some("user"));
let last_text = last3
.get("content")
.and_then(|v| v.as_array())
.and_then(|items| items.first())
.and_then(|item| item.get("text"))
.and_then(|text| text.as_str())
.unwrap_or_default();
assert_eq!(
last_text, SUMMARIZATION_PROMPT,
"auto compact should send the summarization prompt as a user message",
});
assert!(
!saw_compact_prompt,
"no auto compact request should be sent when the summarization prompt exceeds the limit"
);
}
@@ -869,7 +817,7 @@ async fn auto_compact_triggers_after_function_call_over_95_percent_usage() {
let server = start_mock_server().await;
let context_window = 100;
let context_window = 20_000;
let limit = context_window * 90 / 100;
let over_limit_tokens = context_window * 95 / 100 + 1;

View File

@@ -0,0 +1,69 @@
use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
use codex_protocol::user_input::UserInput;
use core_test_support::responses;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event_with_timeout;
use std::sync::Arc;
use std::time::Duration;
use wiremock::matchers::any;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn input_validation_should_fail_for_too_large_input() {
let server = start_mock_server().await;
let fixture = test_codex().build(&server).await.unwrap();
let codex = Arc::clone(&fixture.codex);
// First: normal message with a mocked assistant response
let first_response = sse(vec![
ev_response_created("resp-1"),
ev_assistant_message("msg-1", "ok"),
ev_completed("resp-1"),
]);
responses::mount_sse_once_match(&server, any(), first_response).await;
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: "hello world".into(),
}],
})
.await
.unwrap();
// Wait for the normal turn to complete before sending the oversized input
let turn_timeout = Duration::from_secs(1);
wait_for_event_with_timeout(
&codex,
|ev| matches!(ev, EventMsg::TaskComplete(_)),
turn_timeout,
)
.await;
// Then: 300k-token message should trigger validation error
let wait_timeout = Duration::from_millis(100);
let input_300_tokens = "token ".repeat(300_000);
codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: input_300_tokens,
}],
})
.await
.unwrap();
let error_event =
wait_for_event_with_timeout(&codex, |ev| matches!(ev, EventMsg::Error(_)), wait_timeout)
.await;
let EventMsg::Error(error_event) = error_event else {
unreachable!("wait_for_event_with_timeout returned unexpected payload");
};
assert_eq!(error_event.message, "invalid input: input too large");
}

View File

@@ -13,6 +13,7 @@ mod compact_resume_fork;
mod exec;
mod fork_conversation;
mod grep_files;
mod input_validation;
mod items;
mod json_result;
mod list_dir;

View File

@@ -156,12 +156,6 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> {
"cmd": "/bin/echo END-EVENT".to_string(),
"yield_time_ms": 250,
});
let poll_call_id = "uexec-end-event-poll";
let poll_args = json!({
"chars": "",
"session_id": 0,
"yield_time_ms": 250,
});
let responses = vec![
sse(vec![
@@ -171,17 +165,8 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> {
]),
sse(vec![
ev_response_created("resp-2"),
ev_function_call(
poll_call_id,
"write_stdin",
&serde_json::to_string(&poll_args)?,
),
ev_completed("resp-2"),
]),
sse(vec![
ev_response_created("resp-3"),
ev_assistant_message("msg-1", "finished"),
ev_completed("resp-3"),
ev_completed("resp-2"),
]),
];
mount_sse_sequence(&server, responses).await;

View File

@@ -519,7 +519,6 @@ impl EventProcessor for EventProcessorWithHumanOutput {
EventMsg::AgentReasoningRawContentDelta(_) => {}
EventMsg::ItemStarted(_) => {}
EventMsg::ItemCompleted(_) => {}
EventMsg::RawResponseItem(_) => {}
}
CodexStatus::Running
}

View File

@@ -179,7 +179,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
include_view_image_tool: None,
show_raw_agent_reasoning: oss.then_some(true),
tools_web_search_request: None,
experimental_sandbox_command_assessment: None,
additional_writable_roots: Vec::new(),
};
// Parse `-c` overrides.

View File

@@ -12,7 +12,7 @@ use anyhow::anyhow;
use codex_protocol::ConversationId;
use tracing_subscriber::fmt::writer::MakeWriter;
const DEFAULT_MAX_BYTES: usize = 4 * 1024 * 1024; // 4 MiB
const DEFAULT_MAX_BYTES: usize = 2 * 1024 * 1024; // 2 MiB
const SENTRY_DSN: &str =
"https://ae32ed50620d7a7792c1ce5df38b3e3e@o33249.ingest.us.sentry.io/4510195390611458";
const UPLOAD_TIMEOUT_SECS: u64 = 10;
@@ -167,17 +167,8 @@ impl CodexLogSnapshot {
Ok(path)
}
/// Upload feedback to Sentry with optional attachments.
pub fn upload_feedback(
&self,
classification: &str,
reason: Option<&str>,
cli_version: &str,
include_logs: bool,
rollout_path: Option<&std::path::Path>,
) -> Result<()> {
pub fn upload_to_sentry(&self) -> Result<()> {
use std::collections::BTreeMap;
use std::fs;
use std::str::FromStr;
use std::sync::Arc;
@@ -191,87 +182,33 @@ impl CodexLogSnapshot {
use sentry::transports::DefaultTransportFactory;
use sentry::types::Dsn;
// Build Sentry client
let client = Client::from_config(ClientOptions {
dsn: Some(Dsn::from_str(SENTRY_DSN).map_err(|e| anyhow!("invalid DSN: {e}"))?),
dsn: Some(Dsn::from_str(SENTRY_DSN).map_err(|e| anyhow!("invalid DSN: {}", e))?),
transport: Some(Arc::new(DefaultTransportFactory {})),
..Default::default()
});
let mut tags = BTreeMap::from([
(String::from("thread_id"), self.thread_id.to_string()),
(String::from("classification"), classification.to_string()),
(String::from("cli_version"), cli_version.to_string()),
]);
if let Some(r) = reason {
tags.insert(String::from("reason"), r.to_string());
}
let tags = BTreeMap::from([(String::from("thread_id"), self.thread_id.to_string())]);
let level = match classification {
"bug" | "bad_result" => Level::Error,
_ => Level::Info,
};
let mut envelope = Envelope::new();
let title = format!(
"[{}]: Codex session {}",
display_classification(classification),
self.thread_id
);
let mut event = Event {
level,
message: Some(title.clone()),
let event = Event {
level: Level::Error,
message: Some("Codex Log Upload ".to_string() + &self.thread_id),
tags,
..Default::default()
};
if let Some(r) = reason {
use sentry::protocol::Exception;
use sentry::protocol::Values;
event.exception = Values::from(vec![Exception {
ty: title.clone(),
value: Some(r.to_string()),
..Default::default()
}]);
}
let mut envelope = Envelope::new();
envelope.add_item(EnvelopeItem::Event(event));
if include_logs {
envelope.add_item(EnvelopeItem::Attachment(Attachment {
buffer: self.bytes.clone(),
filename: String::from("codex-logs.log"),
content_type: Some("text/plain".to_string()),
ty: None,
}));
}
if let Some((path, data)) = rollout_path.and_then(|p| fs::read(p).ok().map(|d| (p, d))) {
let fname = path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "rollout.jsonl".to_string());
let content_type = "text/plain".to_string();
envelope.add_item(EnvelopeItem::Attachment(Attachment {
buffer: data,
filename: fname,
content_type: Some(content_type),
ty: None,
}));
}
envelope.add_item(EnvelopeItem::Attachment(Attachment {
buffer: self.bytes.clone(),
filename: String::from("codex-logs.log"),
content_type: Some("text/plain".to_string()),
ty: None,
}));
client.send_envelope(envelope);
client.flush(Some(Duration::from_secs(UPLOAD_TIMEOUT_SECS)));
Ok(())
}
}
fn display_classification(classification: &str) -> String {
match classification {
"bug" => "Bug".to_string(),
"bad_result" => "Bad result".to_string(),
"good_result" => "Good result".to_string(),
_ => "Other".to_string(),
Ok(())
}
}

View File

@@ -158,7 +158,6 @@ impl CodexToolCallParam {
include_view_image_tool: None,
show_raw_agent_reasoning: None,
tools_web_search_request: None,
experimental_sandbox_command_assessment: None,
additional_writable_roots: Vec::new(),
};

View File

@@ -178,7 +178,6 @@ async fn run_codex_tool_session_inner(
cwd,
call_id,
reason: _,
risk,
parsed_cmd,
}) => {
handle_exec_approval_request(
@@ -191,7 +190,6 @@ async fn run_codex_tool_session_inner(
event.id.clone(),
call_id,
parsed_cmd,
risk,
)
.await;
continue;
@@ -285,7 +283,6 @@ async fn run_codex_tool_session_inner(
| EventMsg::UserMessage(_)
| EventMsg::ShutdownComplete
| EventMsg::ViewImageToolCall(_)
| EventMsg::RawResponseItem(_)
| EventMsg::EnteredReviewMode(_)
| EventMsg::ItemStarted(_)
| EventMsg::ItemCompleted(_)

View File

@@ -4,7 +4,6 @@ use std::sync::Arc;
use codex_core::CodexConversation;
use codex_core::protocol::Op;
use codex_core::protocol::ReviewDecision;
use codex_core::protocol::SandboxCommandAssessment;
use codex_protocol::parse_command::ParsedCommand;
use mcp_types::ElicitRequest;
use mcp_types::ElicitRequestParamsRequestedSchema;
@@ -38,8 +37,6 @@ pub struct ExecApprovalElicitRequestParams {
pub codex_command: Vec<String>,
pub codex_cwd: PathBuf,
pub codex_parsed_cmd: Vec<ParsedCommand>,
#[serde(skip_serializing_if = "Option::is_none")]
pub codex_risk: Option<SandboxCommandAssessment>,
}
// TODO(mbolin): ExecApprovalResponse does not conform to ElicitResult. See:
@@ -62,7 +59,6 @@ pub(crate) async fn handle_exec_approval_request(
event_id: String,
call_id: String,
codex_parsed_cmd: Vec<ParsedCommand>,
codex_risk: Option<SandboxCommandAssessment>,
) {
let escaped_command =
shlex::try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" "));
@@ -85,7 +81,6 @@ pub(crate) async fn handle_exec_approval_request(
codex_command: command,
codex_cwd: cwd,
codex_parsed_cmd,
codex_risk,
};
let params_json = match serde_json::to_value(&params) {
Ok(value) => value,

View File

@@ -196,7 +196,6 @@ fn create_expected_elicitation_request(
codex_cwd: workdir.to_path_buf(),
codex_call_id: "call1234".to_string(),
codex_parsed_cmd,
codex_risk: None,
})?),
})
}

View File

@@ -8,8 +8,6 @@ use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SandboxRiskCategory;
use codex_protocol::protocol::SandboxRiskLevel;
use codex_protocol::user_input::UserInput;
use eventsource_stream::Event as StreamEvent;
use eventsource_stream::EventStreamError as StreamError;
@@ -368,63 +366,6 @@ impl OtelEventManager {
);
}
pub fn sandbox_assessment(
&self,
call_id: &str,
status: &str,
risk_level: Option<SandboxRiskLevel>,
risk_categories: &[SandboxRiskCategory],
duration: Duration,
) {
let level = risk_level.map(|level| level.as_str());
let categories = if risk_categories.is_empty() {
String::new()
} else {
risk_categories
.iter()
.map(SandboxRiskCategory::as_str)
.collect::<Vec<_>>()
.join(", ")
};
tracing::event!(
tracing::Level::INFO,
event.name = "codex.sandbox_assessment",
event.timestamp = %timestamp(),
conversation.id = %self.metadata.conversation_id,
app.version = %self.metadata.app_version,
auth_mode = self.metadata.auth_mode,
user.account_id = self.metadata.account_id,
user.email = self.metadata.account_email,
terminal.type = %self.metadata.terminal_type,
model = %self.metadata.model,
slug = %self.metadata.slug,
call_id = %call_id,
status = %status,
risk_level = level,
risk_categories = categories,
duration_ms = %duration.as_millis(),
);
}
pub fn sandbox_assessment_latency(&self, call_id: &str, duration: Duration) {
tracing::event!(
tracing::Level::INFO,
event.name = "codex.sandbox_assessment_latency",
event.timestamp = %timestamp(),
conversation.id = %self.metadata.conversation_id,
app.version = %self.metadata.app_version,
auth_mode = self.metadata.auth_mode,
user.account_id = self.metadata.account_id,
user.email = self.metadata.account_email,
terminal.type = %self.metadata.terminal_type,
model = %self.metadata.model,
slug = %self.metadata.slug,
call_id = %call_id,
duration_ms = %duration.as_millis(),
);
}
pub async fn log_tool_result<F, Fut, E>(
&self,
tool_name: &str,

View File

@@ -1,91 +0,0 @@
use std::collections::HashMap;
use std::path::PathBuf;
use crate::parse_command::ParsedCommand;
use crate::protocol::FileChange;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use ts_rs::TS;
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub enum SandboxRiskLevel {
Low,
Medium,
High,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub enum SandboxRiskCategory {
DataDeletion,
DataExfiltration,
PrivilegeEscalation,
SystemModification,
NetworkAccess,
ResourceExhaustion,
Compliance,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
pub struct SandboxCommandAssessment {
pub description: String,
pub risk_level: SandboxRiskLevel,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub risk_categories: Vec<SandboxRiskCategory>,
}
impl SandboxRiskLevel {
pub fn as_str(&self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
}
}
}
impl SandboxRiskCategory {
pub fn as_str(&self) -> &'static str {
match self {
Self::DataDeletion => "data_deletion",
Self::DataExfiltration => "data_exfiltration",
Self::PrivilegeEscalation => "privilege_escalation",
Self::SystemModification => "system_modification",
Self::NetworkAccess => "network_access",
Self::ResourceExhaustion => "resource_exhaustion",
Self::Compliance => "compliance",
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct ExecApprovalRequestEvent {
/// Identifier for the associated exec call, if available.
pub call_id: String,
/// The command to be executed.
pub command: Vec<String>,
/// The command's working directory.
pub cwd: PathBuf,
/// Optional human-readable reason for the approval (e.g. retry without sandbox).
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
/// Optional model-provided risk assessment describing the blocked command.
#[serde(skip_serializing_if = "Option::is_none")]
pub risk: Option<SandboxCommandAssessment>,
pub parsed_cmd: Vec<ParsedCommand>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct ApplyPatchApprovalRequestEvent {
/// Responses API call id for the associated patch apply call, if available.
pub call_id: String,
pub changes: HashMap<PathBuf, FileChange>,
/// Optional explanatory reason (e.g. request for extra write access).
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
/// When set, the agent is asking the user to allow writes under this root for the remainder of the session.
#[serde(skip_serializing_if = "Option::is_none")]
pub grant_root: Option<PathBuf>,
}

View File

@@ -1,7 +1,6 @@
pub mod account;
mod conversation_id;
pub use conversation_id::ConversationId;
pub mod approvals;
pub mod config_types;
pub mod custom_prompts;
pub mod items;

View File

@@ -34,12 +34,6 @@ use serde_with::serde_as;
use strum_macros::Display;
use ts_rs::TS;
pub use crate::approvals::ApplyPatchApprovalRequestEvent;
pub use crate::approvals::ExecApprovalRequestEvent;
pub use crate::approvals::SandboxCommandAssessment;
pub use crate::approvals::SandboxRiskCategory;
pub use crate::approvals::SandboxRiskLevel;
/// Open/close tags for special user-input blocks. Used across crates to avoid
/// duplicated hardcoded strings.
pub const USER_INSTRUCTIONS_OPEN_TAG: &str = "<user_instructions>";
@@ -527,8 +521,6 @@ pub enum EventMsg {
/// Exited review mode with an optional final result to apply.
ExitedReviewMode(ExitedReviewModeEvent),
RawResponseItem(ResponseItem),
ItemStarted(ItemStartedEvent),
ItemCompleted(ItemCompletedEvent),
}
@@ -583,9 +575,11 @@ pub struct TokenUsage {
pub total_tokens: i64,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, Default)]
pub struct TokenUsageInfo {
/// The total token usage for the session. accumulated from all turns.
pub total_token_usage: TokenUsage,
/// The token usage for the last turn. Received from the API. It's total tokens is the whole window size.
pub last_token_usage: TokenUsage,
#[ts(type = "number | null")]
pub model_context_window: Option<i64>,
@@ -1134,6 +1128,33 @@ pub struct ExecCommandOutputDeltaEvent {
pub chunk: Vec<u8>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct ExecApprovalRequestEvent {
/// Identifier for the associated exec call, if available.
pub call_id: String,
/// The command to be executed.
pub command: Vec<String>,
/// The command's working directory.
pub cwd: PathBuf,
/// Optional human-readable reason for the approval (e.g. retry without sandbox).
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub parsed_cmd: Vec<ParsedCommand>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct ApplyPatchApprovalRequestEvent {
/// Responses API call id for the associated patch apply call, if available.
pub call_id: String,
pub changes: HashMap<PathBuf, FileChange>,
/// Optional explanatory reason (e.g. request for extra write access).
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
/// When set, the agent is asking the user to allow writes under this root for the remainder of the session.
#[serde(skip_serializing_if = "Option::is_none")]
pub grant_root: Option<PathBuf>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct BackgroundEventEvent {
pub message: String,

View File

@@ -360,15 +360,6 @@ impl App {
AppEvent::OpenFullAccessConfirmation { preset } => {
self.chat_widget.open_full_access_confirmation(preset);
}
AppEvent::OpenFeedbackNote {
category,
include_logs,
} => {
self.chat_widget.open_feedback_note(category, include_logs);
}
AppEvent::OpenFeedbackConsent { category } => {
self.chat_widget.open_feedback_consent(category);
}
AppEvent::PersistModelSelection { model, effort } => {
let profile = self.active_profile.as_deref();
match persist_model_selection(&self.config.codex_home, profile, &model, effort)

View File

@@ -101,23 +101,4 @@ pub(crate) enum AppEvent {
/// Open the approval popup.
FullScreenApprovalRequest(ApprovalRequest),
/// Open the feedback note entry overlay after the user selects a category.
OpenFeedbackNote {
category: FeedbackCategory,
include_logs: bool,
},
/// Open the upload consent popup for feedback after selecting a category.
OpenFeedbackConsent {
category: FeedbackCategory,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FeedbackCategory {
BadResult,
GoodResult,
Bug,
Other,
}

View File

@@ -19,9 +19,6 @@ use crate::render::renderable::Renderable;
use codex_core::protocol::FileChange;
use codex_core::protocol::Op;
use codex_core::protocol::ReviewDecision;
use codex_core::protocol::SandboxCommandAssessment;
use codex_core::protocol::SandboxRiskCategory;
use codex_core::protocol::SandboxRiskLevel;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
@@ -41,7 +38,6 @@ pub(crate) enum ApprovalRequest {
id: String,
command: Vec<String>,
reason: Option<String>,
risk: Option<SandboxCommandAssessment>,
},
ApplyPatch {
id: String,
@@ -289,17 +285,12 @@ impl From<ApprovalRequest> for ApprovalRequestState {
id,
command,
reason,
risk,
} => {
let reason = reason.filter(|item| !item.is_empty());
let has_reason = reason.is_some();
let mut header: Vec<Line<'static>> = Vec::new();
if let Some(reason) = reason {
if let Some(reason) = reason
&& !reason.is_empty()
{
header.push(Line::from(vec!["Reason: ".into(), reason.italic()]));
}
if let Some(risk) = risk.as_ref() {
header.extend(render_risk_lines(risk));
} else if has_reason {
header.push(Line::from(""));
}
let full_cmd = strip_bash_lc_and_escape(&command);
@@ -339,52 +330,6 @@ impl From<ApprovalRequest> for ApprovalRequestState {
}
}
fn render_risk_lines(risk: &SandboxCommandAssessment) -> Vec<Line<'static>> {
let level_span = match risk.risk_level {
SandboxRiskLevel::Low => "LOW".green().bold(),
SandboxRiskLevel::Medium => "MEDIUM".cyan().bold(),
SandboxRiskLevel::High => "HIGH".red().bold(),
};
let mut lines = Vec::new();
let description = risk.description.trim();
if !description.is_empty() {
lines.push(Line::from(vec![
"Summary: ".into(),
description.to_string().into(),
]));
}
let mut spans: Vec<Span<'static>> = vec!["Risk: ".into(), level_span];
if !risk.risk_categories.is_empty() {
spans.push(" (".into());
for (idx, category) in risk.risk_categories.iter().enumerate() {
if idx > 0 {
spans.push(", ".into());
}
spans.push(risk_category_label(*category).into());
}
spans.push(")".into());
}
lines.push(Line::from(spans));
lines.push(Line::from(""));
lines
}
fn risk_category_label(category: SandboxRiskCategory) -> &'static str {
match category {
SandboxRiskCategory::DataDeletion => "data deletion",
SandboxRiskCategory::DataExfiltration => "data exfiltration",
SandboxRiskCategory::PrivilegeEscalation => "privilege escalation",
SandboxRiskCategory::SystemModification => "system modification",
SandboxRiskCategory::NetworkAccess => "network access",
SandboxRiskCategory::ResourceExhaustion => "resource exhaustion",
SandboxRiskCategory::Compliance => "compliance",
}
}
#[derive(Clone)]
enum ApprovalVariant {
Exec { id: String, command: Vec<String> },
@@ -459,7 +404,6 @@ mod tests {
id: "test".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
reason: Some("reason".to_string()),
risk: None,
}
}
@@ -501,7 +445,6 @@ mod tests {
id: "test".into(),
command,
reason: None,
risk: None,
};
let view = ApprovalOverlay::new(exec_request, tx);

View File

@@ -52,7 +52,6 @@ use crate::ui_consts::LIVE_PREFIX_COLS;
use codex_file_search::FileMatch;
use std::cell::RefCell;
use std::collections::HashMap;
use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
@@ -62,34 +61,6 @@ use std::time::Instant;
/// placeholder in the UI.
const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
fn maybe_prefix_root_like(path: &Path) -> Option<PathBuf> {
#[cfg(windows)]
{
let _ = path;
None
}
#[cfg(not(windows))]
{
if path.has_root() {
return None;
}
let path_str = path.to_string_lossy();
const ROOT_PREFIXES: [&str; 5] =
["Applications/", "Library/", "System/", "Users/", "Volumes/"];
if ROOT_PREFIXES
.iter()
.any(|prefix| path_str.starts_with(prefix))
{
return Some(PathBuf::from(format!("/{path_str}")));
}
None
}
}
/// Result returned when the user interacts with the text area.
#[derive(Debug, PartialEq)]
pub enum InputResult {
@@ -304,11 +275,11 @@ impl ChatComposer {
return false;
};
match Self::resolve_image_path_with_fallback(path_buf) {
Ok((resolved_path, w, h)) => {
match image::image_dimensions(&path_buf) {
Ok((w, h)) => {
tracing::info!("OK: {pasted}");
let format_label = pasted_image_format(&resolved_path).label();
self.attach_image(resolved_path, w, h, format_label);
let format_label = pasted_image_format(&path_buf).label();
self.attach_image(path_buf, w, h, format_label);
true
}
Err(err) => {
@@ -318,34 +289,6 @@ impl ChatComposer {
}
}
fn resolve_image_path_with_fallback(
path: PathBuf,
) -> Result<(PathBuf, u32, u32), image::ImageError> {
match image::image_dimensions(&path) {
Ok((w, h)) => Ok((path, w, h)),
Err(err) => {
if let image::ImageError::IoError(io_err) = &err
&& io_err.kind() == ErrorKind::NotFound
{
if let Some(fallback) = maybe_prefix_root_like(&path) {
match image::image_dimensions(&fallback) {
Ok((w, h)) => return Ok((fallback, w, h)),
Err(fallback_err) => {
tracing::debug!(
?fallback_err,
original = %path.display(),
fallback = %fallback.display(),
"fallback_dimensions_failed",
);
}
}
}
}
Err(err)
}
}
}
pub(crate) fn set_disable_paste_burst(&mut self, disabled: bool) {
let was_disabled = self.disable_paste_burst;
self.disable_paste_burst = disabled;
@@ -3505,20 +3448,4 @@ mod tests {
assert_eq!(composer.textarea.text(), "z".repeat(count));
assert!(composer.pending_pastes.is_empty());
}
#[cfg(not(windows))]
#[test]
fn maybe_prefix_root_like_adds_leading_slash() {
let input = PathBuf::from("Users/example/image.png");
let result = maybe_prefix_root_like(&input);
assert_eq!(result, Some(PathBuf::from("/Users/example/image.png")));
}
#[cfg(not(windows))]
#[test]
fn maybe_prefix_root_like_ignores_relative_dirs() {
let input = PathBuf::from("project/assets/image.png");
let result = maybe_prefix_root_like(&input);
assert!(result.is_none());
}
}

View File

@@ -1,448 +1,165 @@
use std::cell::RefCell;
use std::path::PathBuf;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::history_cell;
use crate::history_cell::PlainHistoryCell;
use crate::render::renderable::Renderable;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::Widget;
use std::path::PathBuf;
use crate::app_event::AppEvent;
use crate::app_event::FeedbackCategory;
use crate::app_event_sender::AppEventSender;
use crate::history_cell;
use crate::render::renderable::Renderable;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
use super::popup_consts::standard_popup_hint_line;
use super::textarea::TextArea;
use super::textarea::TextAreaState;
use super::BottomPane;
use super::SelectionAction;
use super::SelectionItem;
use super::SelectionViewParams;
const BASE_ISSUE_URL: &str = "https://github.com/openai/codex/issues/new?template=2-bug-report.yml";
/// Minimal input overlay to collect an optional feedback note, then upload
/// both logs and rollout with classification + metadata.
pub(crate) struct FeedbackNoteView {
category: FeedbackCategory,
snapshot: codex_feedback::CodexLogSnapshot,
rollout_path: Option<PathBuf>,
app_event_tx: AppEventSender,
include_logs: bool,
pub(crate) struct FeedbackView;
// UI state
textarea: TextArea,
textarea_state: RefCell<TextAreaState>,
complete: bool,
}
impl FeedbackNoteView {
pub(crate) fn new(
category: FeedbackCategory,
impl FeedbackView {
pub fn show(
bottom_pane: &mut BottomPane,
file_path: PathBuf,
snapshot: codex_feedback::CodexLogSnapshot,
rollout_path: Option<PathBuf>,
app_event_tx: AppEventSender,
include_logs: bool,
) -> Self {
Self {
category,
snapshot,
rollout_path,
app_event_tx,
include_logs,
textarea: TextArea::new(),
textarea_state: RefCell::new(TextAreaState::default()),
complete: false,
}
) {
bottom_pane.show_selection_view(Self::selection_params(file_path, snapshot));
}
fn submit(&mut self) {
let note = self.textarea.text().trim().to_string();
let reason_opt = if note.is_empty() {
None
} else {
Some(note.as_str())
};
let rollout_path_ref = self.rollout_path.as_deref();
let classification = feedback_classification(self.category);
fn selection_params(
file_path: PathBuf,
snapshot: codex_feedback::CodexLogSnapshot,
) -> SelectionViewParams {
let header = FeedbackHeader::new(file_path);
let cli_version = crate::version::CODEX_CLI_VERSION;
let mut thread_id = self.snapshot.thread_id.clone();
let thread_id = snapshot.thread_id.clone();
let result = self.snapshot.upload_feedback(
classification,
reason_opt,
cli_version,
self.include_logs,
if self.include_logs {
rollout_path_ref
} else {
None
},
);
match result {
Ok(()) => {
let issue_url = format!("{BASE_ISSUE_URL}&steps=Uploaded%20thread:%20{thread_id}");
let prefix = if self.include_logs {
"• Feedback uploaded."
} else {
"• Feedback recorded (no logs)."
};
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::PlainHistoryCell::new(vec![
Line::from(format!(
"{prefix} Please open an issue using the following URL:"
)),
let upload_action_tread_id = thread_id.clone();
let upload_action: SelectionAction = Box::new(move |tx: &AppEventSender| {
match snapshot.upload_to_sentry() {
Ok(()) => {
let issue_url = format!(
"{BASE_ISSUE_URL}&steps=Uploaded%20thread:%20{upload_action_tread_id}",
);
tx.send(AppEvent::InsertHistoryCell(Box::new(PlainHistoryCell::new(vec![
Line::from(
"• Codex logs uploaded. Please open an issue using the following URL:",
),
"".into(),
Line::from(vec![" ".into(), issue_url.cyan().underlined()]),
"".into(),
Line::from(vec![
" Or mention your thread ID ".into(),
std::mem::take(&mut thread_id).bold(),
" in an existing issue.".into(),
]),
]),
)));
Line::from(vec![" Or mention your thread ID ".into(), upload_action_tread_id.clone().bold(), " in an existing issue.".into()])
]))));
}
Err(e) => {
tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event(format!("Failed to upload logs: {e}")),
)));
}
}
Err(e) => {
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event(format!("Failed to upload feedback: {e}")),
)));
}
}
self.complete = true;
}
}
});
impl BottomPaneView for FeedbackNoteView {
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event {
KeyEvent {
code: KeyCode::Esc, ..
} => {
self.on_ctrl_c();
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
self.submit();
}
KeyEvent {
code: KeyCode::Enter,
..
} => {
self.textarea.input(key_event);
}
other => {
self.textarea.input(other);
}
}
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
self.complete = true;
CancellationEvent::Handled
}
fn is_complete(&self) -> bool {
self.complete
}
fn handle_paste(&mut self, pasted: String) -> bool {
if pasted.is_empty() {
return false;
}
self.textarea.insert_str(&pasted);
true
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
if area.height < 2 || area.width <= 2 {
return None;
}
let text_area_height = self.input_height(area.width).saturating_sub(1);
if text_area_height == 0 {
return None;
}
let top_line_count = 1u16; // title only
let textarea_rect = Rect {
x: area.x.saturating_add(2),
y: area.y.saturating_add(top_line_count).saturating_add(1),
width: area.width.saturating_sub(2),
height: text_area_height,
let upload_item = SelectionItem {
name: "Yes".to_string(),
description: Some(
"Share the current Codex session logs with the team for troubleshooting."
.to_string(),
),
actions: vec![upload_action],
dismiss_on_select: true,
..Default::default()
};
let state = *self.textarea_state.borrow();
self.textarea.cursor_pos_with_state(textarea_rect, state)
let no_action: SelectionAction = Box::new(move |tx: &AppEventSender| {
let issue_url = format!("{BASE_ISSUE_URL}&steps=Thread%20ID:%20{thread_id}",);
tx.send(AppEvent::InsertHistoryCell(Box::new(
PlainHistoryCell::new(vec![
Line::from("• Please open an issue using the following URL:"),
"".into(),
Line::from(vec![" ".into(), issue_url.cyan().underlined()]),
"".into(),
Line::from(vec![
" Or mention your thread ID ".into(),
thread_id.clone().bold(),
" in an existing issue.".into(),
]),
]),
)));
});
let no_item = SelectionItem {
name: "No".to_string(),
actions: vec![no_action],
dismiss_on_select: true,
..Default::default()
};
let cancel_item = SelectionItem {
name: "Cancel".to_string(),
dismiss_on_select: true,
..Default::default()
};
SelectionViewParams {
header: Box::new(header),
items: vec![upload_item, no_item, cancel_item],
..Default::default()
}
}
}
impl Renderable for FeedbackNoteView {
fn desired_height(&self, width: u16) -> u16 {
1u16 + self.input_height(width) + 3u16
struct FeedbackHeader {
file_path: PathBuf,
}
impl FeedbackHeader {
fn new(file_path: PathBuf) -> Self {
Self { file_path }
}
fn lines(&self) -> Vec<Line<'static>> {
vec![
Line::from("Do you want to upload logs before reporting issue?".bold()),
"".into(),
Line::from(
"Logs may include the full conversation history of this Codex process, including prompts, tool calls, and their results.",
),
Line::from(
"These logs are retained for 90 days and are used solely for troubleshooting and diagnostic purposes.",
),
"".into(),
Line::from(vec![
"You can review the exact content of the logs before theyre uploaded at:".into(),
]),
Line::from(self.file_path.display().to_string().dim()),
"".into(),
]
}
}
impl Renderable for FeedbackHeader {
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
if area.width == 0 || area.height == 0 {
return;
}
let (title, placeholder) = feedback_title_and_placeholder(self.category);
let input_height = self.input_height(area.width);
// Title line
let title_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: 1,
};
let title_spans: Vec<Span<'static>> = vec![gutter(), title.bold()];
Paragraph::new(Line::from(title_spans)).render(title_area, buf);
// Input line
let input_area = Rect {
x: area.x,
y: area.y.saturating_add(1),
width: area.width,
height: input_height,
};
if input_area.width >= 2 {
for row in 0..input_area.height {
Paragraph::new(Line::from(vec![gutter()])).render(
Rect {
x: input_area.x,
y: input_area.y.saturating_add(row),
width: 2,
height: 1,
},
buf,
);
for (i, line) in self.lines().into_iter().enumerate() {
let y = area.y.saturating_add(i as u16);
if y >= area.y.saturating_add(area.height) {
break;
}
let text_area_height = input_area.height.saturating_sub(1);
if text_area_height > 0 {
if input_area.width > 2 {
let blank_rect = Rect {
x: input_area.x.saturating_add(2),
y: input_area.y,
width: input_area.width.saturating_sub(2),
height: 1,
};
Clear.render(blank_rect, buf);
}
let textarea_rect = Rect {
x: input_area.x.saturating_add(2),
y: input_area.y.saturating_add(1),
width: input_area.width.saturating_sub(2),
height: text_area_height,
};
let mut state = self.textarea_state.borrow_mut();
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
if self.textarea.text().is_empty() {
Paragraph::new(Line::from(placeholder.dim())).render(textarea_rect, buf);
}
}
}
let hint_blank_y = input_area.y.saturating_add(input_height);
if hint_blank_y < area.y.saturating_add(area.height) {
let blank_area = Rect {
x: area.x,
y: hint_blank_y,
width: area.width,
height: 1,
};
Clear.render(blank_area, buf);
}
let hint_y = hint_blank_y.saturating_add(1);
if hint_y < area.y.saturating_add(area.height) {
Paragraph::new(standard_popup_hint_line()).render(
Rect {
x: area.x,
y: hint_y,
width: area.width,
height: 1,
},
buf,
);
let line_area = Rect::new(area.x, y, area.width, 1).intersection(area);
line.render(line_area, buf);
}
}
}
impl FeedbackNoteView {
fn input_height(&self, width: u16) -> u16 {
let usable_width = width.saturating_sub(2);
let text_height = self.textarea.desired_height(usable_width).clamp(1, 8);
text_height.saturating_add(1).min(9)
}
}
fn gutter() -> Span<'static> {
"".cyan()
}
fn feedback_title_and_placeholder(category: FeedbackCategory) -> (String, String) {
match category {
FeedbackCategory::BadResult => (
"Tell us more (bad result)".to_string(),
"(optional) Write a short description to help us further".to_string(),
),
FeedbackCategory::GoodResult => (
"Tell us more (good result)".to_string(),
"(optional) Write a short description to help us further".to_string(),
),
FeedbackCategory::Bug => (
"Tell us more (bug)".to_string(),
"(optional) Write a short description to help us further".to_string(),
),
FeedbackCategory::Other => (
"Tell us more (other)".to_string(),
"(optional) Write a short description to help us further".to_string(),
),
}
}
fn feedback_classification(category: FeedbackCategory) -> &'static str {
match category {
FeedbackCategory::BadResult => "bad_result",
FeedbackCategory::GoodResult => "good_result",
FeedbackCategory::Bug => "bug",
FeedbackCategory::Other => "other",
}
}
// Build the selection popup params for feedback categories.
pub(crate) fn feedback_selection_params(
app_event_tx: AppEventSender,
) -> super::SelectionViewParams {
super::SelectionViewParams {
title: Some("How was this?".to_string()),
items: vec![
make_feedback_item(
app_event_tx.clone(),
"bug",
"Crash, error message, hang, or broken UI/behavior.",
FeedbackCategory::Bug,
),
make_feedback_item(
app_event_tx.clone(),
"bad result",
"Output was off-target, incorrect, incomplete, or unhelpful.",
FeedbackCategory::BadResult,
),
make_feedback_item(
app_event_tx.clone(),
"good result",
"Helpful, correct, highquality, or delightful result worth celebrating.",
FeedbackCategory::GoodResult,
),
make_feedback_item(
app_event_tx,
"other",
"Slowness, feature suggestion, UX feedback, or anything else.",
FeedbackCategory::Other,
),
],
..Default::default()
}
}
fn make_feedback_item(
app_event_tx: AppEventSender,
name: &str,
description: &str,
category: FeedbackCategory,
) -> super::SelectionItem {
let action: super::SelectionAction = Box::new(move |_sender: &AppEventSender| {
app_event_tx.send(AppEvent::OpenFeedbackConsent { category });
});
super::SelectionItem {
name: name.to_string(),
description: Some(description.to_string()),
actions: vec![action],
dismiss_on_select: true,
..Default::default()
}
}
/// Build the upload consent popup params for a given feedback category.
pub(crate) fn feedback_upload_consent_params(
app_event_tx: AppEventSender,
category: FeedbackCategory,
rollout_path: Option<std::path::PathBuf>,
) -> super::SelectionViewParams {
use super::popup_consts::standard_popup_hint_line;
let yes_action: super::SelectionAction = Box::new({
let tx = app_event_tx.clone();
move |sender: &AppEventSender| {
let _ = sender;
tx.send(AppEvent::OpenFeedbackNote {
category,
include_logs: true,
});
}
});
let no_action: super::SelectionAction = Box::new({
let tx = app_event_tx;
move |sender: &AppEventSender| {
let _ = sender;
tx.send(AppEvent::OpenFeedbackNote {
category,
include_logs: false,
});
}
});
// Build header listing files that would be sent if user consents.
let mut header_lines: Vec<Box<dyn crate::render::renderable::Renderable>> = vec![
Line::from("Upload logs?".bold()).into(),
Line::from("").into(),
Line::from("The following files will be sent:".dim()).into(),
Line::from(vec!["".into(), "codex-logs.log".into()]).into(),
];
if let Some(path) = rollout_path.as_deref()
&& let Some(name) = path.file_name().map(|s| s.to_string_lossy().to_string())
{
header_lines.push(Line::from(vec!["".into(), name.into()]).into());
}
super::SelectionViewParams {
footer_hint: Some(standard_popup_hint_line()),
items: vec![
super::SelectionItem {
name: "Yes".to_string(),
description: Some(
"Share the current Codex session logs with the team for troubleshooting."
.to_string(),
),
actions: vec![yes_action],
dismiss_on_select: true,
..Default::default()
},
super::SelectionItem {
name: "No".to_string(),
description: Some("".to_string()),
actions: vec![no_action],
dismiss_on_select: true,
..Default::default()
},
],
header: Box::new(crate::render::renderable::ColumnRenderable::with(
header_lines,
)),
..Default::default()
fn desired_height(&self, width: u16) -> u16 {
self.lines()
.iter()
.map(|line| line.desired_height(width))
.sum()
}
}
@@ -450,19 +167,22 @@ pub(crate) fn feedback_upload_consent_params(
mod tests {
use super::*;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::list_selection_view::ListSelectionView;
use crate::style::user_message_style;
use codex_feedback::CodexFeedback;
use codex_protocol::ConversationId;
use insta::assert_snapshot;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use tokio::sync::mpsc::unbounded_channel;
fn render(view: &FeedbackNoteView, width: u16) -> String {
let height = view.desired_height(width);
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
let mut lines: Vec<String> = (0..area.height)
fn buffer_to_string(buffer: &Buffer) -> String {
(0..buffer.area.height)
.map(|row| {
let mut line = String::new();
for col in 0..area.width {
let symbol = buf[(area.x + col, area.y + row)].symbol();
for col in 0..buffer.area.width {
let symbol = buffer[(buffer.area.x + col, buffer.area.y + row)].symbol();
if symbol.is_empty() {
line.push(' ');
} else {
@@ -471,49 +191,34 @@ mod tests {
}
line.trim_end().to_string()
})
.collect();
while lines.first().is_some_and(|l| l.trim().is_empty()) {
lines.remove(0);
}
while lines.last().is_some_and(|l| l.trim().is_empty()) {
lines.pop();
}
lines.join("\n")
}
fn make_view(category: FeedbackCategory) -> FeedbackNoteView {
let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let snapshot = codex_feedback::CodexFeedback::new().snapshot(None);
FeedbackNoteView::new(category, snapshot, None, tx, true)
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn feedback_view_bad_result() {
let view = make_view(FeedbackCategory::BadResult);
let rendered = render(&view, 60);
insta::assert_snapshot!("feedback_view_bad_result", rendered);
}
fn renders_feedback_view_header() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let app_event_tx = AppEventSender::new(tx_raw);
let snapshot = CodexFeedback::new().snapshot(Some(
ConversationId::from_string("550e8400-e29b-41d4-a716-446655440000").unwrap(),
));
let file_path = PathBuf::from("/tmp/codex-feedback.log");
#[test]
fn feedback_view_good_result() {
let view = make_view(FeedbackCategory::GoodResult);
let rendered = render(&view, 60);
insta::assert_snapshot!("feedback_view_good_result", rendered);
}
let params = FeedbackView::selection_params(file_path.clone(), snapshot);
let view = ListSelectionView::new(params, app_event_tx);
#[test]
fn feedback_view_bug() {
let view = make_view(FeedbackCategory::Bug);
let rendered = render(&view, 60);
insta::assert_snapshot!("feedback_view_bug", rendered);
}
let width = 72;
let height = view.desired_height(width).max(1);
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
#[test]
fn feedback_view_other() {
let view = make_view(FeedbackCategory::Other);
let rendered = render(&view, 60);
insta::assert_snapshot!("feedback_view_other", rendered);
let rendered =
buffer_to_string(&buf).replace(&file_path.display().to_string(), "<LOG_PATH>");
assert_snapshot!("feedback_view_render", rendered);
let cell_style = buf[(area.x, area.y)].style();
let expected_bg = user_message_style().bg.unwrap_or(Color::Reset);
assert_eq!(cell_style.bg.unwrap_or(Color::Reset), expected_bg);
}
}

View File

@@ -28,14 +28,12 @@ mod list_selection_view;
mod prompt_args;
pub(crate) use list_selection_view::SelectionViewParams;
mod feedback_view;
pub(crate) use feedback_view::feedback_selection_params;
pub(crate) use feedback_view::feedback_upload_consent_params;
mod paste_burst;
pub mod popup_consts;
mod scroll_state;
mod selection_popup_common;
mod textarea;
pub(crate) use feedback_view::FeedbackNoteView;
pub(crate) use feedback_view::FeedbackView;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CancellationEvent {
@@ -559,7 +557,6 @@ mod tests {
id: "1".to_string(),
command: vec!["echo".into(), "ok".into()],
reason: None,
risk: None,
}
}

View File

@@ -1,9 +0,0 @@
---
source: tui/src/bottom_pane/feedback_view.rs
expression: rendered
---
▌ Tell us more (bad result)
▌ (optional) Write a short description to help us further
Press enter to confirm or esc to go back

View File

@@ -1,9 +0,0 @@
---
source: tui/src/bottom_pane/feedback_view.rs
expression: rendered
---
▌ Tell us more (bug)
▌ (optional) Write a short description to help us further
Press enter to confirm or esc to go back

View File

@@ -1,9 +0,0 @@
---
source: tui/src/bottom_pane/feedback_view.rs
expression: rendered
---
▌ Tell us more (good result)
▌ (optional) Write a short description to help us further
Press enter to confirm or esc to go back

View File

@@ -1,9 +0,0 @@
---
source: tui/src/bottom_pane/feedback_view.rs
expression: rendered
---
▌ Tell us more (other)
▌ (optional) Write a short description to help us further
Press enter to confirm or esc to go back

View File

@@ -276,8 +276,6 @@ pub(crate) struct ChatWidget {
last_rendered_width: std::cell::Cell<Option<usize>>,
// Feedback sink for /feedback
feedback: codex_feedback::CodexFeedback,
// Current session rollout path (if known)
current_rollout_path: Option<PathBuf>,
}
struct UserMessage {
@@ -324,7 +322,6 @@ impl ChatWidget {
self.bottom_pane
.set_history_metadata(event.history_log_id, event.history_entry_count);
self.conversation_id = Some(event.session_id);
self.current_rollout_path = Some(event.rollout_path.clone());
let initial_messages = event.initial_messages.clone();
let model_for_header = event.model.clone();
self.session_header.set_model(&model_for_header);
@@ -346,39 +343,6 @@ impl ChatWidget {
}
}
pub(crate) fn open_feedback_note(
&mut self,
category: crate::app_event::FeedbackCategory,
include_logs: bool,
) {
// Build a fresh snapshot at the time of opening the note overlay.
let snapshot = self.feedback.snapshot(self.conversation_id);
let rollout = if include_logs {
self.current_rollout_path.clone()
} else {
None
};
let view = crate::bottom_pane::FeedbackNoteView::new(
category,
snapshot,
rollout,
self.app_event_tx.clone(),
include_logs,
);
self.bottom_pane.show_view(Box::new(view));
self.request_redraw();
}
pub(crate) fn open_feedback_consent(&mut self, category: crate::app_event::FeedbackCategory) {
let params = crate::bottom_pane::feedback_upload_consent_params(
self.app_event_tx.clone(),
category,
self.current_rollout_path.clone(),
);
self.bottom_pane.show_selection_view(params);
self.request_redraw();
}
fn on_agent_message(&mut self, message: String) {
// If we have a stream_controller, then the final agent message is redundant and will be a
// duplicate of what has already been streamed.
@@ -532,7 +496,7 @@ impl ChatWidget {
if reason != TurnAbortReason::ReviewEnded {
self.add_to_history(history_cell::new_error_event(
"Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.".to_owned(),
"Conversation interrupted - tell the model what to do differently".to_owned(),
));
}
@@ -813,7 +777,6 @@ impl ChatWidget {
id,
command: ev.command,
reason: ev.reason,
risk: ev.risk,
};
self.bottom_pane.push_approval_request(request);
self.request_redraw();
@@ -994,7 +957,6 @@ impl ChatWidget {
needs_final_message_separator: false,
last_rendered_width: std::cell::Cell::new(None),
feedback,
current_rollout_path: None,
}
}
@@ -1062,7 +1024,6 @@ impl ChatWidget {
needs_final_message_separator: false,
last_rendered_width: std::cell::Cell::new(None),
feedback,
current_rollout_path: None,
}
}
@@ -1167,11 +1128,23 @@ impl ChatWidget {
}
match cmd {
SlashCommand::Feedback => {
// Step 1: pick a category (UI built in feedback_view)
let params =
crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone());
self.bottom_pane.show_selection_view(params);
self.request_redraw();
let snapshot = self.feedback.snapshot(self.conversation_id);
match snapshot.save_to_temp_file() {
Ok(path) => {
crate::bottom_pane::FeedbackView::show(
&mut self.bottom_pane,
path,
snapshot,
);
self.request_redraw();
}
Err(e) => {
self.add_to_history(history_cell::new_error_event(format!(
"Failed to save feedback logs: {e}"
)));
self.request_redraw();
}
}
}
SlashCommand::New => {
self.app_event_tx.send(AppEvent::NewSession);
@@ -1524,9 +1497,7 @@ impl ChatWidget {
self.on_entered_review_mode(review_request)
}
EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review),
EventMsg::RawResponseItem(_)
| EventMsg::ItemStarted(_)
| EventMsg::ItemCompleted(_) => {}
EventMsg::ItemStarted(_) | EventMsg::ItemCompleted(_) => {}
}
}
@@ -1660,7 +1631,6 @@ impl ChatWidget {
context_usage,
&self.conversation_id,
self.rate_limit_snapshot.as_ref(),
Local::now(),
));
}

View File

@@ -1,11 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
How was this?
1. bug Crash, error message, hang, or broken UI/behavior.
2. bad result Output was off-target, incorrect, incomplete, or unhelpful.
3. good result Helpful, correct, highquality, or delightful result worth
celebrating.
4. other Slowness, feature suggestion, UX feedback, or anything else.

View File

@@ -1,14 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Upload logs?
The following files will be sent:
• codex-logs.log
1. Yes Share the current Codex session logs with the team for
troubleshooting.
2. No
Press enter to confirm or esc to go back

View File

@@ -1,5 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: last
---
■ Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.

View File

@@ -299,7 +299,6 @@ fn make_chatwidget_manual() -> (
needs_final_message_separator: false,
last_rendered_width: std::cell::Cell::new(None),
feedback: codex_feedback::CodexFeedback::new(),
current_rollout_path: None,
};
(widget, rx, op_rx)
}
@@ -403,7 +402,6 @@ fn exec_approval_emits_proposed_command_and_decision_history() {
reason: Some(
"this is a test reason such as one that would be produced by the model".into(),
),
risk: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
@@ -446,7 +444,6 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() {
reason: Some(
"this is a test reason such as one that would be produced by the model".into(),
),
risk: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
@@ -495,7 +492,6 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() {
command: vec!["bash".into(), "-lc".into(), long],
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
reason: None,
risk: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
@@ -999,37 +995,6 @@ fn interrupt_exec_marks_failed_snapshot() {
assert_snapshot!("interrupt_exec_marks_failed", exec_blob);
}
// Snapshot test: after an interrupted turn, a gentle error message is inserted
// suggesting the user to tell the model what to do differently and to use /feedback.
#[test]
fn interrupted_turn_error_message_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
// Simulate an in-progress task so the widget is in a running state.
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TaskStarted(TaskStartedEvent {
model_context_window: None,
}),
});
// Abort the turn (like pressing Esc) and drain inserted history.
chat.handle_codex_event(Event {
id: "task-1".into(),
msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent {
reason: TurnAbortReason::Interrupted,
}),
});
let cells = drain_insert_history(&mut rx);
assert!(
!cells.is_empty(),
"expected error message to be inserted after interruption"
);
let last = lines_to_single_string(cells.last().unwrap());
assert_snapshot!("interrupted_turn_error_message", last);
}
/// Opening custom prompt from the review popup, pressing Esc returns to the
/// parent popup, pressing Esc again dismisses all panels (back to normal mode).
#[test]
@@ -1207,28 +1172,6 @@ fn model_reasoning_selection_popup_snapshot() {
assert_snapshot!("model_reasoning_selection_popup", popup);
}
#[test]
fn feedback_selection_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
// Open the feedback category selection popup via slash command.
chat.dispatch_command(SlashCommand::Feedback);
let popup = render_bottom_popup(&chat, 80);
assert_snapshot!("feedback_selection_popup", popup);
}
#[test]
fn feedback_upload_consent_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
// Open the consent popup directly for a chosen category.
chat.open_feedback_consent(crate::app_event::FeedbackCategory::Bug);
let popup = render_bottom_popup(&chat, 80);
assert_snapshot!("feedback_upload_consent_popup", popup);
}
#[test]
fn reasoning_popup_escape_returns_to_model_popup() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
@@ -1478,7 +1421,6 @@ fn approval_modal_exec_snapshot() {
reason: Some(
"this is a test reason such as one that would be produced by the model".into(),
),
risk: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
@@ -1523,7 +1465,6 @@ fn approval_modal_exec_without_reason_snapshot() {
command: vec!["bash".into(), "-lc".into(), "echo hello world".into()],
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
reason: None,
risk: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {
@@ -1734,7 +1675,6 @@ fn status_widget_and_approval_modal_snapshot() {
reason: Some(
"this is a test reason such as one that would be produced by the model".into(),
),
risk: None,
parsed_cmd: vec![],
};
chat.handle_codex_event(Event {

View File

@@ -187,36 +187,12 @@ pub fn normalize_pasted_path(pasted: &str) -> Option<PathBuf> {
// shell-escaped single path → unescaped
let parts: Vec<String> = shlex::Shlex::new(pasted).collect();
if parts.len() == 1 {
let mut path = parts.into_iter().next()?;
#[cfg(not(windows))]
{
path = fixup_unix_root_relative_path(path);
}
return Some(PathBuf::from(path));
return parts.into_iter().next().map(PathBuf::from);
}
None
}
#[cfg(not(windows))]
fn fixup_unix_root_relative_path(mut path: String) -> String {
use std::path::Path;
if Path::new(&path).has_root() {
return path;
}
const ROOT_PREFIXES: [&str; 5] = ["Applications/", "Library/", "System/", "Users/", "Volumes/"];
if ROOT_PREFIXES.iter().any(|prefix| path.starts_with(prefix)) {
path.insert(0, '/');
}
path
}
/// Infer an image format for the provided path based on its extension.
pub fn pasted_image_format(path: &Path) -> EncodedImageFormat {
match path
@@ -279,25 +255,6 @@ mod pasted_paths_tests {
assert!(result.is_none());
}
#[cfg(not(windows))]
#[test]
fn normalize_dragged_finder_users_path() {
let input = "'Users/alice/Pictures/example.png'";
let result = normalize_pasted_path(input).expect("should add leading slash for Users/");
assert_eq!(result, PathBuf::from("/Users/alice/Pictures/example.png"));
}
#[cfg(not(windows))]
#[test]
fn normalize_dragged_finder_volumes_path() {
let input = "'Volumes/ExternalDrive/photos/image.jpg'";
let result = normalize_pasted_path(input).expect("should add leading slash for Volumes/");
assert_eq!(
result,
PathBuf::from("/Volumes/ExternalDrive/photos/image.jpg")
);
}
#[test]
fn pasted_image_format_png_jpeg_unknown() {
assert_eq!(

View File

@@ -1047,10 +1047,7 @@ pub(crate) fn new_mcp_tools_output(
return PlainHistoryCell { lines };
}
let mut servers: Vec<_> = config.mcp_servers.iter().collect();
servers.sort_by(|(a, _), (b, _)| a.cmp(b));
for (server, cfg) in servers {
for (server, cfg) in config.mcp_servers.iter() {
let prefix = format!("mcp__{server}__");
let mut names: Vec<String> = tools
.keys()
@@ -1114,7 +1111,7 @@ pub(crate) fn new_mcp_tools_output(
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
let display = pairs
.into_iter()
.map(|(name, _)| format!("{name}=*****"))
.map(|(name, value)| format!("{name}={value}"))
.collect::<Vec<_>>()
.join(", ");
lines.push(vec![" • HTTP headers: ".into(), display.into()].into());
@@ -1126,7 +1123,7 @@ pub(crate) fn new_mcp_tools_output(
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
let display = pairs
.into_iter()
.map(|(name, var)| format!("{name}={var}"))
.map(|(name, env_var)| format!("{name}={env_var}"))
.collect::<Vec<_>>()
.join(", ");
lines.push(vec![" • Env HTTP headers: ".into(), display.into()].into());
@@ -1418,20 +1415,14 @@ mod tests {
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
use codex_core::config_types::McpServerConfig;
use codex_core::config_types::McpServerTransportConfig;
use codex_core::protocol::McpAuthStatus;
use codex_protocol::parse_command::ParsedCommand;
use dirs::home_dir;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::HashMap;
use mcp_types::CallToolResult;
use mcp_types::ContentBlock;
use mcp_types::TextContent;
use mcp_types::Tool;
use mcp_types::ToolInputSchema;
fn test_config() -> Config {
Config::load_from_base_config_with_overrides(
@@ -1458,91 +1449,6 @@ mod tests {
render_lines(&cell.transcript_lines(u16::MAX))
}
#[test]
fn mcp_tools_output_masks_sensitive_values() {
let mut config = test_config();
let mut env = HashMap::new();
env.insert("TOKEN".to_string(), "secret".to_string());
let stdio_config = McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: "docs-server".to_string(),
args: vec![],
env: Some(env),
env_vars: vec!["APP_TOKEN".to_string()],
cwd: None,
},
enabled: true,
startup_timeout_sec: None,
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
};
config.mcp_servers.insert("docs".to_string(), stdio_config);
let mut headers = HashMap::new();
headers.insert("Authorization".to_string(), "Bearer secret".to_string());
let mut env_headers = HashMap::new();
env_headers.insert("X-API-Key".to_string(), "API_KEY_ENV".to_string());
let http_config = McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
url: "https://example.com/mcp".to_string(),
bearer_token_env_var: Some("MCP_TOKEN".to_string()),
http_headers: Some(headers),
env_http_headers: Some(env_headers),
},
enabled: true,
startup_timeout_sec: None,
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
};
config.mcp_servers.insert("http".to_string(), http_config);
let mut tools: HashMap<String, Tool> = HashMap::new();
tools.insert(
"mcp__docs__list".to_string(),
Tool {
annotations: None,
description: None,
input_schema: ToolInputSchema {
properties: None,
required: None,
r#type: "object".to_string(),
},
name: "list".to_string(),
output_schema: None,
title: None,
},
);
tools.insert(
"mcp__http__ping".to_string(),
Tool {
annotations: None,
description: None,
input_schema: ToolInputSchema {
properties: None,
required: None,
r#type: "object".to_string(),
},
name: "ping".to_string(),
output_schema: None,
title: None,
},
);
let auth_statuses: HashMap<String, McpAuthStatus> = HashMap::new();
let cell = new_mcp_tools_output(
&config,
tools,
HashMap::new(),
HashMap::new(),
&auth_statuses,
);
let rendered = render_lines(&cell.display_lines(120)).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn empty_agent_message_cell_transcript() {
let cell = AgentMessageCell::new(vec![Line::default()], false);

View File

@@ -148,7 +148,6 @@ pub async fn run_main(
include_view_image_tool: None,
show_raw_agent_reasoning: cli.oss.then_some(true),
tools_web_search_request: cli.web_search.then_some(true),
experimental_sandbox_command_assessment: None,
additional_writable_roots: additional_dirs,
};
let raw_overrides = cli.config_overrides.raw_overrides.clone();

View File

@@ -1,27 +0,0 @@
---
source: tui/src/history_cell.rs
assertion_line: 1540
expression: rendered
---
/mcp
🔌 MCP Tools
• docs
• Status: enabled
• Auth: Unsupported
• Command: docs-server
• Env: TOKEN=*****, APP_TOKEN=*****
• Tools: list
• Resources: (none)
• Resource templates: (none)
• http
• Status: enabled
• Auth: Unsupported
• URL: https://example.com/mcp
• HTTP headers: Authorization=*****
• Env HTTP headers: X-API-Key=API_KEY_ENV
• Tools: ping
• Resources: (none)
• Resource templates: (none)

View File

@@ -3,8 +3,6 @@ use crate::history_cell::HistoryCell;
use crate::history_cell::PlainHistoryCell;
use crate::history_cell::with_border_with_inner_width;
use crate::version::CODEX_CLI_VERSION;
use chrono::DateTime;
use chrono::Local;
use codex_common::create_config_summary_entries;
use codex_core::config::Config;
use codex_core::protocol::SandboxPolicy;
@@ -27,7 +25,6 @@ use super::helpers::format_directory_display;
use super::helpers::format_tokens_compact;
use super::rate_limits::RateLimitSnapshotDisplay;
use super::rate_limits::StatusRateLimitData;
use super::rate_limits::StatusRateLimitRow;
use super::rate_limits::compose_rate_limit_data;
use super::rate_limits::format_status_limit_summary;
use super::rate_limits::render_status_limit_progress_bar;
@@ -67,17 +64,9 @@ pub(crate) fn new_status_output(
context_usage: Option<&TokenUsage>,
session_id: &Option<ConversationId>,
rate_limits: Option<&RateLimitSnapshotDisplay>,
now: DateTime<Local>,
) -> CompositeHistoryCell {
let command = PlainHistoryCell::new(vec!["/status".magenta().into()]);
let card = StatusHistoryCell::new(
config,
total_usage,
context_usage,
session_id,
rate_limits,
now,
);
let card = StatusHistoryCell::new(config, total_usage, context_usage, session_id, rate_limits);
CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)])
}
@@ -89,7 +78,6 @@ impl StatusHistoryCell {
context_usage: Option<&TokenUsage>,
session_id: &Option<ConversationId>,
rate_limits: Option<&RateLimitSnapshotDisplay>,
now: DateTime<Local>,
) -> Self {
let config_entries = create_config_summary_entries(config);
let (model_name, model_details) = compose_model_display(config, &config_entries);
@@ -120,7 +108,7 @@ impl StatusHistoryCell {
output: total_usage.output_tokens,
context_window,
};
let rate_limits = compose_rate_limit_data(rate_limits, now);
let rate_limits = compose_rate_limit_data(rate_limits);
Self {
model_name,
@@ -183,66 +171,47 @@ impl StatusHistoryCell {
];
}
self.rate_limit_row_lines(rows_data, available_inner_width, formatter)
}
StatusRateLimitData::Stale(rows_data) => {
let mut lines =
self.rate_limit_row_lines(rows_data, available_inner_width, formatter);
lines.push(formatter.line(
"Warning",
vec![Span::from("limits may be stale - start new turn to refresh.").dim()],
));
let mut lines = Vec::with_capacity(rows_data.len() * 2);
for row in rows_data {
let value_spans = vec![
Span::from(render_status_limit_progress_bar(row.percent_used)),
Span::from(" "),
Span::from(format_status_limit_summary(row.percent_used)),
];
let base_spans = formatter.full_spans(row.label.as_str(), value_spans);
let base_line = Line::from(base_spans.clone());
if let Some(resets_at) = row.resets_at.as_ref() {
let resets_span = Span::from(format!("(resets {resets_at})")).dim();
let mut inline_spans = base_spans.clone();
inline_spans.push(Span::from(" ").dim());
inline_spans.push(resets_span.clone());
if line_display_width(&Line::from(inline_spans.clone()))
<= available_inner_width
{
lines.push(Line::from(inline_spans));
} else {
lines.push(base_line);
lines.push(formatter.continuation(vec![resets_span]));
}
} else {
lines.push(base_line);
}
}
lines
}
StatusRateLimitData::Missing => {
vec![formatter.line(
"Limits",
vec![
Span::from("visit ").dim(),
"chatgpt.com/codex/settings/usage".cyan().underlined(),
],
vec![Span::from("send a message to load usage data").dim()],
)]
}
}
}
fn rate_limit_row_lines(
&self,
rows: &[StatusRateLimitRow],
available_inner_width: usize,
formatter: &FieldFormatter,
) -> Vec<Line<'static>> {
let mut lines = Vec::with_capacity(rows.len().saturating_mul(2));
for row in rows {
let value_spans = vec![
Span::from(render_status_limit_progress_bar(row.percent_used)),
Span::from(" "),
Span::from(format_status_limit_summary(row.percent_used)),
];
let base_spans = formatter.full_spans(row.label.as_str(), value_spans);
let base_line = Line::from(base_spans.clone());
if let Some(resets_at) = row.resets_at.as_ref() {
let resets_span = Span::from(format!("(resets {resets_at})")).dim();
let mut inline_spans = base_spans.clone();
inline_spans.push(Span::from(" ").dim());
inline_spans.push(resets_span.clone());
if line_display_width(&Line::from(inline_spans.clone())) <= available_inner_width {
lines.push(Line::from(inline_spans));
} else {
lines.push(base_line);
lines.push(formatter.continuation(vec![resets_span]));
}
} else {
lines.push(base_line);
}
}
lines
}
fn collect_rate_limit_labels(&self, seen: &mut BTreeSet<String>, labels: &mut Vec<String>) {
match &self.rate_limits {
StatusRateLimitData::Available(rows) => {
@@ -254,12 +223,6 @@ impl StatusHistoryCell {
}
}
}
StatusRateLimitData::Stale(rows) => {
for row in rows {
push_label(labels, seen, row.label.as_str());
}
push_label(labels, seen, "Warning");
}
StatusRateLimitData::Missing => push_label(labels, seen, "Limits"),
}
}

View File

@@ -2,7 +2,6 @@ use crate::chatwidget::get_limits_duration;
use super::helpers::format_reset_timestamp;
use chrono::DateTime;
use chrono::Duration as ChronoDuration;
use chrono::Local;
use chrono::Utc;
use codex_core::protocol::RateLimitSnapshot;
@@ -22,12 +21,9 @@ pub(crate) struct StatusRateLimitRow {
#[derive(Debug, Clone)]
pub(crate) enum StatusRateLimitData {
Available(Vec<StatusRateLimitRow>),
Stale(Vec<StatusRateLimitRow>),
Missing,
}
pub(crate) const RATE_LIMIT_STALE_THRESHOLD_MINUTES: i64 = 15;
#[derive(Debug, Clone)]
pub(crate) struct RateLimitWindowDisplay {
pub used_percent: f64,
@@ -53,7 +49,6 @@ impl RateLimitWindowDisplay {
#[derive(Debug, Clone)]
pub(crate) struct RateLimitSnapshotDisplay {
pub captured_at: DateTime<Local>,
pub primary: Option<RateLimitWindowDisplay>,
pub secondary: Option<RateLimitWindowDisplay>,
}
@@ -63,7 +58,6 @@ pub(crate) fn rate_limit_snapshot_display(
captured_at: DateTime<Local>,
) -> RateLimitSnapshotDisplay {
RateLimitSnapshotDisplay {
captured_at,
primary: snapshot
.primary
.as_ref()
@@ -77,7 +71,6 @@ pub(crate) fn rate_limit_snapshot_display(
pub(crate) fn compose_rate_limit_data(
snapshot: Option<&RateLimitSnapshotDisplay>,
now: DateTime<Local>,
) -> StatusRateLimitData {
match snapshot {
Some(snapshot) => {
@@ -109,13 +102,8 @@ pub(crate) fn compose_rate_limit_data(
});
}
let is_stale = now.signed_duration_since(snapshot.captured_at)
> ChronoDuration::minutes(RATE_LIMIT_STALE_THRESHOLD_MINUTES);
if rows.is_empty() {
StatusRateLimitData::Available(vec![])
} else if is_stale {
StatusRateLimitData::Stale(rows)
} else {
StatusRateLimitData::Available(rows)
}

View File

@@ -15,5 +15,5 @@ expression: sanitized
│ │
│ Token usage: 750 total (500 input + 250 output) │
│ Context window: 100% left (750 used / 272K) │
│ Limits: visit chatgpt.com/codex/settings/usage
│ Limits: send a message to load usage data
╰─────────────────────────────────────────────────────────────────╯

View File

@@ -1,21 +0,0 @@
---
source: tui/src/status/tests.rs
expression: sanitized
---
/status
╭─────────────────────────────────────────────────────────────────────╮
│ >_ OpenAI Codex (v0.0.0) │
│ │
│ Model: gpt-5-codex (reasoning none, summaries auto) │
│ Directory: [[workspace]] │
│ Approval: on-request │
│ Sandbox: read-only │
│ Agents.md: <none> │
│ │
│ Token usage: 1.9K total (1K input + 900 output) │
│ Context window: 100% left (2.1K used / 272K) │
│ 5h limit: [███████████████░░░░░] 72% used (resets 03:14) │
│ Weekly limit: [████████░░░░░░░░░░░░] 40% used (resets 03:34) │
│ Warning: limits may be stale - start new turn to refresh. │
╰─────────────────────────────────────────────────────────────────────╯

View File

@@ -111,14 +111,7 @@ fn status_snapshot_includes_reasoning_details() {
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let composite = new_status_output(
&config,
&usage,
Some(&usage),
&None,
Some(&rate_display),
captured_at,
);
let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display));
let mut rendered_lines = render_lines(&composite.display_lines(80));
if cfg!(windows) {
for line in &mut rendered_lines {
@@ -159,14 +152,7 @@ fn status_snapshot_includes_monthly_limit() {
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let composite = new_status_output(
&config,
&usage,
Some(&usage),
&None,
Some(&rate_display),
captured_at,
);
let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display));
let mut rendered_lines = render_lines(&composite.display_lines(80));
if cfg!(windows) {
for line in &mut rendered_lines {
@@ -192,12 +178,7 @@ fn status_card_token_usage_excludes_cached_tokens() {
total_tokens: 2_100,
};
let now = chrono::Local
.with_ymd_and_hms(2024, 1, 1, 0, 0, 0)
.single()
.expect("timestamp");
let composite = new_status_output(&config, &usage, Some(&usage), &None, None, now);
let composite = new_status_output(&config, &usage, Some(&usage), &None, None);
let rendered = render_lines(&composite.display_lines(120));
assert!(
@@ -238,14 +219,7 @@ fn status_snapshot_truncates_in_narrow_terminal() {
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let composite = new_status_output(
&config,
&usage,
Some(&usage),
&None,
Some(&rate_display),
captured_at,
);
let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display));
let mut rendered_lines = render_lines(&composite.display_lines(46));
if cfg!(windows) {
for line in &mut rendered_lines {
@@ -272,12 +246,7 @@ fn status_snapshot_shows_missing_limits_message() {
total_tokens: 750,
};
let now = chrono::Local
.with_ymd_and_hms(2024, 2, 3, 4, 5, 6)
.single()
.expect("timestamp");
let composite = new_status_output(&config, &usage, Some(&usage), &None, None, now);
let composite = new_status_output(&config, &usage, Some(&usage), &None, None);
let mut rendered_lines = render_lines(&composite.display_lines(80));
if cfg!(windows) {
for line in &mut rendered_lines {
@@ -313,66 +282,7 @@ fn status_snapshot_shows_empty_limits_message() {
.expect("timestamp");
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let composite = new_status_output(
&config,
&usage,
Some(&usage),
&None,
Some(&rate_display),
captured_at,
);
let mut rendered_lines = render_lines(&composite.display_lines(80));
if cfg!(windows) {
for line in &mut rendered_lines {
*line = line.replace('\\', "/");
}
}
let sanitized = sanitize_directory(rendered_lines).join("\n");
assert_snapshot!(sanitized);
}
#[test]
fn status_snapshot_shows_stale_limits_message() {
let temp_home = TempDir::new().expect("temp home");
let mut config = test_config(&temp_home);
config.model = "gpt-5-codex".to_string();
config.cwd = PathBuf::from("/workspace/tests");
let usage = TokenUsage {
input_tokens: 1_200,
cached_input_tokens: 200,
output_tokens: 900,
reasoning_output_tokens: 150,
total_tokens: 2_250,
};
let captured_at = chrono::Local
.with_ymd_and_hms(2024, 1, 2, 3, 4, 5)
.single()
.expect("timestamp");
let snapshot = RateLimitSnapshot {
primary: Some(RateLimitWindow {
used_percent: 72.5,
window_minutes: Some(300),
resets_at: Some(reset_at_from(&captured_at, 600)),
}),
secondary: Some(RateLimitWindow {
used_percent: 40.0,
window_minutes: Some(10_080),
resets_at: Some(reset_at_from(&captured_at, 1_800)),
}),
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let now = captured_at + ChronoDuration::minutes(20);
let composite = new_status_output(
&config,
&usage,
Some(&usage),
&None,
Some(&rate_display),
now,
);
let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display));
let mut rendered_lines = render_lines(&composite.display_lines(80));
if cfg!(windows) {
for line in &mut rendered_lines {
@@ -404,12 +314,7 @@ fn status_context_window_uses_last_usage() {
total_tokens: 13_679,
};
let now = chrono::Local
.with_ymd_and_hms(2024, 6, 1, 12, 0, 0)
.single()
.expect("timestamp");
let composite = new_status_output(&config, &total_usage, Some(&last_usage), &None, None, now);
let composite = new_status_output(&config, &total_usage, Some(&last_usage), &None, None);
let rendered_lines = render_lines(&composite.display_lines(80));
let context_line = rendered_lines
.into_iter()

View File

@@ -107,6 +107,12 @@ impl Tokenizer {
}
}
impl fmt::Debug for Tokenizer {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Tokenizer").finish()
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -417,7 +417,7 @@ cwd = "/Users/<user>/code/my-server"
[mcp_servers.figma]
url = "https://mcp.linear.app/mcp"
# Optional environment variable containing a bearer token to use for auth
bearer_token_env_var = "ENV_VAR"
bearer_token_env_var = "<token>"
# Optional map of headers with hard-coded values.
http_headers = { "HEADER_NAME" = "HEADER_VALUE" }
# Optional map of headers whose values will be replaced with the environment variable.

View File

@@ -42,14 +42,3 @@ Running Codex directly on Windows may work, but is not officially supported. We
### Where should I start after installation?
Follow the quick setup in [Install & build](./install.md) and then jump into [Getting started](./getting-started.md) for interactive usage tips, prompt examples, and AGENTS.md guidance.
### `brew upgrade codex` isn't upgrading me
If you're running Codex v0.46.0 or older, `brew upgrade codex` will not move you to the latest version because we migrated from a Homebrew formula to a cask. To upgrade, uninstall the existing oudated formula and then install the new cask:
```bash
brew uninstall --formula codex
brew install --cask codex
```
After reinstalling, `brew upgrade --cask codex` will keep future releases up to date.

View File

@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import { codexExecSpy } from "./codexExecSpy";
import { describe, expect, it, xit } from "@jest/globals";
import { describe, expect, it } from "@jest/globals";
import { Codex } from "../src/codex";
@@ -308,8 +308,7 @@ describe("Codex", () => {
await close();
}
});
// TODO(pakrym): unskip the test
xit("forwards images to exec", async () => {
it("forwards images to exec", async () => {
const { url, close } = await startResponsesTestProxy({
statusCode: 200,
responseBodies: [