merge main into code-branch-20250926-063412 before merge

This commit is contained in:
James Peter
2025-09-26 22:39:13 +10:00
9 changed files with 793 additions and 420 deletions
+451 -334
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -72,6 +72,7 @@ clap = "4"
clap_complete = "4"
color-eyre = "0.6.3"
crossterm = "0.28.1"
ctor = "0.5.0"
derive_more = "2"
diffy = "0.4.2"
dirs = "6"
+10
View File
@@ -29,6 +29,7 @@ codex-protocol = { workspace = true }
codex-protocol-ts = { workspace = true }
codex-tui = { workspace = true }
codex-version = { path = "../codex-version" }
ctor = { workspace = true }
futures = { workspace = true }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip", "json"] }
regex = "1"
@@ -52,6 +53,15 @@ tokio = { workspace = true, features = [
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
libc = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies]
libc = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
libc = { workspace = true }
[dev-dependencies]
assert_cmd = { workspace = true }
predicates = { workspace = true }
+28
View File
@@ -15,6 +15,7 @@ use codex_cli::login::run_login_with_chatgpt;
use codex_cli::login::run_logout;
use codex_cli::proto;
mod llm;
mod pre_main_hardening;
use llm::{LlmCli, run_llm};
use codex_common::CliConfigOverrides;
use codex_core::find_conversation_path_by_id_str;
@@ -32,6 +33,33 @@ use crate::mcp_cmd::McpCli;
use crate::proto::ProtoCli;
const CLI_COMMAND_NAME: &str = "code";
pub(crate) const CODEX_SECURE_MODE_ENV_VAR: &str = "CODEX_SECURE_MODE";
/// As early as possible in the process lifecycle, apply hardening measures
/// if the CODEX_SECURE_MODE environment variable is set to "1".
#[ctor::ctor]
fn pre_main_hardening() {
let secure_mode = match std::env::var(CODEX_SECURE_MODE_ENV_VAR) {
Ok(value) => value,
Err(_) => return,
};
if secure_mode == "1" {
#[cfg(any(target_os = "linux", target_os = "android"))]
crate::pre_main_hardening::pre_main_hardening_linux();
#[cfg(target_os = "macos")]
crate::pre_main_hardening::pre_main_hardening_macos();
#[cfg(windows)]
crate::pre_main_hardening::pre_main_hardening_windows();
}
// Always clear this env var so child processes don't inherit it.
unsafe {
std::env::remove_var(CODEX_SECURE_MODE_ENV_VAR);
}
}
/// Codex CLI
///
+86
View File
@@ -0,0 +1,86 @@
#[cfg(any(target_os = "linux", target_os = "android"))]
const PRCTL_FAILED_EXIT_CODE: i32 = 5;
#[cfg(target_os = "macos")]
const PTRACE_DENY_ATTACH_FAILED_EXIT_CODE: i32 = 6;
#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))]
const SET_RLIMIT_CORE_FAILED_EXIT_CODE: i32 = 7;
#[cfg(any(target_os = "linux", target_os = "android"))]
pub(crate) fn pre_main_hardening_linux() {
// Disable ptrace attach / mark process non-dumpable.
let ret_code = unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0) };
if ret_code != 0 {
eprintln!(
"ERROR: prctl(PR_SET_DUMPABLE, 0) failed: {}",
std::io::Error::last_os_error()
);
std::process::exit(PRCTL_FAILED_EXIT_CODE);
}
// For "defense in depth," set the core file size limit to 0.
set_core_file_size_limit_to_zero();
// Official Codex releases are MUSL-linked, which means that variables such
// as LD_PRELOAD are ignored anyway, but just to be sure, clear them here.
let ld_keys: Vec<String> = std::env::vars()
.filter_map(|(key, _)| key.starts_with("LD_").then_some(key))
.collect();
for key in ld_keys {
unsafe {
std::env::remove_var(key);
}
}
}
#[cfg(target_os = "macos")]
pub(crate) fn pre_main_hardening_macos() {
// Prevent debuggers from attaching to this process.
let ret_code = unsafe { libc::ptrace(libc::PT_DENY_ATTACH, 0, std::ptr::null_mut(), 0) };
if ret_code == -1 {
eprintln!(
"ERROR: ptrace(PT_DENY_ATTACH) failed: {}",
std::io::Error::last_os_error()
);
std::process::exit(PTRACE_DENY_ATTACH_FAILED_EXIT_CODE);
}
// Set the core file size limit to 0 to prevent core dumps.
set_core_file_size_limit_to_zero();
// Remove all DYLD_ environment variables, which can be used to subvert
// library loading.
let dyld_keys: Vec<String> = std::env::vars()
.filter_map(|(key, _)| key.starts_with("DYLD_").then_some(key))
.collect();
for key in dyld_keys {
unsafe {
std::env::remove_var(key);
}
}
}
#[cfg(unix)]
fn set_core_file_size_limit_to_zero() {
let rlim = libc::rlimit {
rlim_cur: 0,
rlim_max: 0,
};
let ret_code = unsafe { libc::setrlimit(libc::RLIMIT_CORE, &rlim) };
if ret_code != 0 {
eprintln!(
"ERROR: setrlimit(RLIMIT_CORE) failed: {}",
std::io::Error::last_os_error()
);
std::process::exit(SET_RLIMIT_CORE_FAILED_EXIT_CODE);
}
}
#[cfg(windows)]
pub(crate) fn pre_main_hardening_windows() {
// TODO: Perform the appropriate configuration for Windows.
}
+62 -41
View File
@@ -243,52 +243,73 @@ impl App<'_> {
// back off to reduce CPU when idle.
let hot_typing = Instant::now().duration_since(last_key_time) <= Duration::from_millis(250);
let poll_timeout = if hot_typing { Duration::from_millis(2) } else { Duration::from_millis(10) };
if let Ok(true) = crossterm::event::poll(poll_timeout) {
if let Ok(event) = crossterm::event::read() {
match event {
crossterm::event::Event::Key(key_event) => {
// Some Windows terminals (e.g., legacy conhost) only report
// `Release` events when keyboard enhancement flags are not
// supported. Preserve those events so onboarding works there.
if !drop_release_events
|| matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
{
last_key_time = Instant::now();
app_event_tx.send(AppEvent::KeyEvent(key_event));
match crossterm::event::poll(poll_timeout) {
Ok(true) => match crossterm::event::read() {
Ok(event) => {
match event {
crossterm::event::Event::Key(key_event) => {
// Some Windows terminals (e.g., legacy conhost) only report
// `Release` events when keyboard enhancement flags are not
// supported. Preserve those events so onboarding works there.
if !drop_release_events
|| matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
{
last_key_time = Instant::now();
app_event_tx.send(AppEvent::KeyEvent(key_event));
}
}
crossterm::event::Event::Resize(_, _) => {
app_event_tx.send(AppEvent::RequestRedraw);
}
// When the terminal/tab regains focus, issue a redraw.
// Some terminals clear the alt‑screen buffer on focus switches,
// which can leave the status bar and inline images blank until
// the next resize. A focus‑gain repaint fixes this immediately.
crossterm::event::Event::FocusGained => {
app_event_tx.send(AppEvent::RequestRedraw);
}
crossterm::event::Event::FocusLost => {
// No action needed; keep state as‑is.
}
crossterm::event::Event::Paste(pasted) => {
// Many terminals convert newlines to \r when pasting (e.g., iTerm2),
// but tui-textarea expects \n. Normalize CR to LF.
// [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783
// [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
let pasted = pasted.replace("\r", "\n");
app_event_tx.send(AppEvent::Paste(pasted));
}
crossterm::event::Event::Mouse(mouse_event) => {
app_event_tx.send(AppEvent::MouseEvent(mouse_event));
}
// All other event variants are explicitly handled above.
}
crossterm::event::Event::Resize(_, _) => {
app_event_tx.send(AppEvent::RequestRedraw);
}
Err(err) => {
if err.kind() == std::io::ErrorKind::Interrupted {
continue;
}
// When the terminal/tab regains focus, issue a redraw.
// Some terminals clear the alt‑screen buffer on focus switches,
// which can leave the status bar and inline images blank until
// the next resize. A focus‑gain repaint fixes this immediately.
crossterm::event::Event::FocusGained => {
app_event_tx.send(AppEvent::RequestRedraw);
}
crossterm::event::Event::FocusLost => {
// No action needed; keep state as‑is.
}
crossterm::event::Event::Paste(pasted) => {
// Many terminals convert newlines to \r when pasting (e.g., iTerm2),
// but tui-textarea expects \n. Normalize CR to LF.
// [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783
// [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
let pasted = pasted.replace("\r", "\n");
app_event_tx.send(AppEvent::Paste(pasted));
}
crossterm::event::Event::Mouse(mouse_event) => {
app_event_tx.send(AppEvent::MouseEvent(mouse_event));
}
// All other event variants are explicitly handled above.
tracing::error!("input thread failed to read event: {err}");
input_running_thread.store(false, Ordering::Release);
app_event_tx.send(AppEvent::ExitRequest);
break;
}
},
Ok(false) => {
// Timeout expired, no `Event` is available. If the user is typing
// keep the loop hot; otherwise sleep briefly to cut idle CPU.
if !hot_typing {
std::thread::sleep(Duration::from_millis(5));
}
}
} else {
// Timeout expired, no `Event` is available. If the user is typing
// keep the loop hot; otherwise sleep briefly to cut idle CPU.
if !hot_typing {
std::thread::sleep(Duration::from_millis(5));
Err(err) => {
if err.kind() == std::io::ErrorKind::Interrupted {
continue;
}
tracing::error!("input thread failed to poll events: {err}");
input_running_thread.store(false, Ordering::Release);
app_event_tx.send(AppEvent::ExitRequest);
break;
}
}
}
+122 -38
View File
@@ -105,6 +105,7 @@ pub(crate) struct ChatComposer {
// Tracks a one-off Tab-triggered file search. When set, we will only
// create/show a popup if the results are non-empty to avoid flicker.
pending_tab_file_query: Option<String>,
file_popup_origin: Option<FilePopupOrigin>,
pending_pastes: Vec<(String, String)>,
token_usage_info: Option<TokenUsageInfo>,
has_focus: bool,
@@ -145,6 +146,11 @@ enum ActivePopup {
File(FileSearchPopup),
}
enum FilePopupOrigin {
Auto,
Manual { token: String },
}
impl ChatComposer {
pub fn new(
has_input_focus: bool,
@@ -165,6 +171,7 @@ impl ChatComposer {
dismissed_file_popup_token: None,
current_file_query: None,
pending_tab_file_query: None,
file_popup_origin: None,
pending_pastes: Vec::new(),
token_usage_info: None,
has_focus: has_input_focus,
@@ -694,10 +701,42 @@ impl ChatComposer {
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
// Handle one-off Tab-triggered case first: only open if matches exist.
if self.pending_tab_file_query.as_ref() == Some(&query) {
// If the user kept typing while the search was in-flight, resync to the
// latest token before applying stale results.
if let Some(current_token) = Self::current_generic_token(&self.textarea) {
if Self::current_completion_token(&self.textarea).is_some() {
// A new auto-triggerable token (e.g., @ or ./) should be handled by the
// standard auto completion path instead of hijacking the manual popup.
self.pending_tab_file_query = None;
self.file_popup_origin = None;
self.current_file_query = None;
return;
}
if !current_token.is_empty() && current_token != query {
self.pending_tab_file_query = Some(current_token.clone());
if let ActivePopup::File(popup) = &mut self.active_popup {
popup.set_query(&current_token);
} else {
let mut popup = FileSearchPopup::new();
popup.set_query(&current_token);
self.active_popup = ActivePopup::File(popup);
}
self.current_file_query = Some(current_token.clone());
self.file_popup_origin = Some(FilePopupOrigin::Manual { token: current_token.clone() });
self.app_event_tx
.send(crate::app_event::AppEvent::StartFileSearch(current_token));
return;
}
}
// Clear pending regardless of result to avoid repeats.
self.pending_tab_file_query = None;
if matches.is_empty() {
if let ActivePopup::File(popup) = &mut self.active_popup {
// Clear the waiting state so the popup shows "no matches" instead of spinning forever.
popup.set_matches(&query, Vec::new());
}
return; // do not open popup when no matches to avoid flicker
}
@@ -710,11 +749,21 @@ impl ChatComposer {
self.active_popup = ActivePopup::File(popup);
}
}
self.current_file_query = Some(query);
self.current_file_query = Some(query.clone());
self.file_popup_origin = Some(FilePopupOrigin::Manual { token: query });
self.dismissed_file_popup_token = None;
return;
}
if matches!(self.file_popup_origin, Some(FilePopupOrigin::Manual { .. }))
&& self.current_file_query.as_ref() == Some(&query)
{
if let ActivePopup::File(popup) = &mut self.active_popup {
popup.set_matches(&query, matches);
}
return;
}
// Otherwise, only apply if user is still editing a token matching the query
// and that token qualifies for auto-trigger (i.e., @ or ./).
let current_opt = Self::current_completion_token(&self.textarea);
@@ -751,6 +800,8 @@ impl ChatComposer {
match self.active_popup {
ActivePopup::File(_) => {
self.active_popup = ActivePopup::None;
self.file_popup_origin = None;
self.current_file_query = None;
true
}
_ => false,
@@ -991,6 +1042,8 @@ impl ChatComposer {
self.dismissed_file_popup_token = Some(tok.to_string());
}
self.active_popup = ActivePopup::None;
self.file_popup_origin = None;
self.current_file_query = None;
(InputResult::None, true)
}
KeyEvent {
@@ -1006,6 +1059,8 @@ impl ChatComposer {
// Drop popup borrow before using self mutably again.
self.insert_selected_path(&sel_path);
self.active_popup = ActivePopup::None;
self.file_popup_origin = None;
self.current_file_query = None;
return (InputResult::None, true);
}
(InputResult::None, false)
@@ -1556,48 +1611,77 @@ impl ChatComposer {
/// Note this is only called when self.active_popup is NOT Command.
fn sync_file_search_popup(&mut self) {
// Determine if there is a token underneath the cursor worth completing.
let query = match Self::current_completion_token(&self.textarea) {
Some(token) => token,
match Self::current_completion_token(&self.textarea) {
Some(query) => {
if self.dismissed_file_popup_token.as_ref() == Some(&query) {
return;
}
if query.chars().count() >= 1 {
self.app_event_tx
.send(crate::app_event::AppEvent::StartFileSearch(query.clone()));
}
match &mut self.active_popup {
ActivePopup::File(popup) => {
if query.is_empty() {
popup.set_empty_prompt();
} else {
popup.set_query(&query);
}
}
_ => {
let mut popup = FileSearchPopup::new();
if query.is_empty() {
popup.set_empty_prompt();
} else {
popup.set_query(&query);
}
self.active_popup = ActivePopup::File(popup);
}
}
self.current_file_query = Some(query);
self.file_popup_origin = Some(FilePopupOrigin::Auto);
self.dismissed_file_popup_token = None;
}
None => {
// Allow manually-triggered popups (via Tab) to stay open while the
// cursor remains within the same generic token. When the token
// changes, trigger a new search; otherwise keep the popup stable.
if let Some(FilePopupOrigin::Manual { token }) = &mut self.file_popup_origin {
if let Some(generic) = Self::current_generic_token(&self.textarea) {
if generic.is_empty() {
self.active_popup = ActivePopup::None;
self.dismissed_file_popup_token = None;
self.file_popup_origin = None;
self.current_file_query = None;
} else {
if *token != generic {
*token = generic.clone();
if let ActivePopup::File(popup) = &mut self.active_popup {
popup.set_query(&generic);
} else {
let mut popup = FileSearchPopup::new();
popup.set_query(&generic);
self.active_popup = ActivePopup::File(popup);
}
self.current_file_query = Some(generic.clone());
self.app_event_tx
.send(crate::app_event::AppEvent::StartFileSearch(generic));
}
// Keep popup visible while token is still active.
}
return;
}
}
self.active_popup = ActivePopup::None;
self.dismissed_file_popup_token = None;
return;
}
};
// If user dismissed popup for this exact query, don't reopen until text changes.
if self.dismissed_file_popup_token.as_ref() == Some(&query) {
return;
}
// Trigger file search as soon as at least 1 character is typed.
// The popup shows an idle hint for an empty query handled above.
if query.chars().count() >= 1 {
self.app_event_tx
.send(crate::app_event::AppEvent::StartFileSearch(query.clone()));
}
match &mut self.active_popup {
ActivePopup::File(popup) => {
if query.is_empty() {
popup.set_empty_prompt();
} else {
popup.set_query(&query);
}
}
_ => {
let mut popup = FileSearchPopup::new();
if query.is_empty() {
popup.set_empty_prompt();
} else {
popup.set_query(&query);
}
self.active_popup = ActivePopup::File(popup);
self.file_popup_origin = None;
self.current_file_query = None;
}
}
self.current_file_query = Some(query);
self.dismissed_file_popup_token = None;
}
pub(crate) fn set_has_focus(&mut self, has_focus: bool) {
+14
View File
@@ -322,6 +322,13 @@ fn limits_overlay_renders_snapshot() {
.iter()
.position(|line| line.contains("7 Day History"))
.expect("expected usage header");
assert!(
strings
.get(header_idx.saturating_sub(1))
.map(|line| line.trim().is_empty())
.unwrap_or(false),
"expected blank spacer before 7 Day History"
);
let latest_label = Local::now().format("%b %d").to_string();
let yesterday_label = (Local::now().date_naive() - ChronoDuration::days(1))
.format("%b %d")
@@ -343,6 +350,13 @@ fn limits_overlay_renders_snapshot() {
.iter()
.position(|line| line.contains("6 Month History"))
.expect("expected monthly usage header");
assert!(
strings
.get(month_header_idx.saturating_sub(1))
.map(|line| line.trim().is_empty())
.unwrap_or(false),
"expected blank spacer before 6 Month History"
);
let current_month_label = Local::now().format("%b %Y").to_string();
let month_line = strings
.get(month_header_idx + 1)
+19 -7
View File
@@ -403,12 +403,21 @@ fn print_timing_summary(summary: &str) {
#[allow(clippy::print_stdout, clippy::print_stderr)]
fn cleanup_session_worktrees_and_print() {
use std::process::Command;
let pid = std::process::id();
let home = match std::env::var_os("HOME") { Some(h) => std::path::PathBuf::from(h), None => return };
let session_dir = home.join(".code").join("working").join("_session");
let file = session_dir.join(format!("pid-{}.txt", pid));
let Ok(data) = std::fs::read_to_string(&file) else { return };
reclaim_worktrees_from_file(&file, "current session");
}
fn reclaim_worktrees_from_file(path: &std::path::Path, label: &str) {
use std::process::Command;
let Ok(data) = std::fs::read_to_string(path) else {
let _ = std::fs::remove_file(path);
return;
};
let mut entries: Vec<(std::path::PathBuf, std::path::PathBuf)> = Vec::new();
for line in data.lines() {
if line.trim().is_empty() { continue; }
@@ -416,22 +425,25 @@ fn cleanup_session_worktrees_and_print() {
entries.push((std::path::PathBuf::from(root_s), std::path::PathBuf::from(path_s)));
}
}
// Deduplicate paths in case of retries
use std::collections::HashSet;
let mut seen = HashSet::new();
entries.retain(|(_, p)| seen.insert(p.clone()));
if entries.is_empty() { let _ = std::fs::remove_file(&file); return; }
if entries.is_empty() {
let _ = std::fs::remove_file(path);
return;
}
eprintln!("Cleaning remaining worktrees ({}).", entries.len());
eprintln!("Cleaning remaining worktrees for {} ({}).", label, entries.len());
for (git_root, worktree) in entries {
let wt_str = match worktree.to_str() { Some(s) => s, None => continue };
let Some(wt_str) = worktree.to_str() else { continue };
let _ = Command::new("git")
.current_dir(&git_root)
.args(["worktree", "remove", wt_str, "--force"])
.output();
let _ = std::fs::remove_dir_all(&worktree);
}
let _ = std::fs::remove_file(&file);
let _ = std::fs::remove_file(path);
}
fn maybe_apply_terminal_theme_detection(config: &mut Config, theme_configured_explicitly: bool) {