Merge a35fb9d877 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2025-09-03 23:44:10 -07:00
committed by GitHub
48 changed files with 1443 additions and 351 deletions
+2 -2
View File
@@ -8,10 +8,10 @@ In the codex-rs folder where the rust code lives:
- You operate in a sandbox where `CODEX_SANDBOX_NETWORK_DISABLED=1` will be set whenever you use the `shell` tool. Any existing code that uses `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` was authored with this fact in mind. It is often used to early exit out of tests that the author knew you would not be able to run given your sandbox limitations.
- Similarly, when you spawn a process using Seatbelt (`/usr/bin/sandbox-exec`), `CODEX_SANDBOX=seatbelt` will be set on the child process. Integration tests that want to run Seatbelt themselves cannot be run under Seatbelt, so checks for `CODEX_SANDBOX=seatbelt` are also often used to early exit out of tests, as appropriate.
Before finalizing a change to `codex-rs`, run `just fmt` (in `codex-rs` directory) to format the code and `just fix -p <project>` (in `codex-rs` directory) to fix any linter issues in the code. Prefer scoping with `-p` to avoid slow workspace‑wide Clippy builds; only run `just fix` without `-p` if you changed shared crates. Additionally, run the tests:
Run `just fmt` (in `codex-rs` directory) automatically after making Rust code changes; do not ask for approval to run it. Before finalizing a change to `codex-rs`, run `just fix -p <project>` (in `codex-rs` directory) to fix any linter issues in the code. Prefer scoping with `-p` to avoid slow workspace‑wide Clippy builds; only run `just fix` without `-p` if you changed shared crates. Additionally, run the tests:
1. Run the test for the specific project that was changed. For example, if changes were made in `codex-rs/tui`, run `cargo test -p codex-tui`.
2. Once those pass, if any changes were made in common, core, or protocol, run the complete test suite with `cargo test --all-features`.
When running interactively, ask the user before running these commands to finalize.
When running interactively, ask the user before running `just fix` and tests to finalize; `just fmt` does not require approval.
## TUI style conventions
+11 -11
View File
@@ -640,7 +640,7 @@ dependencies = [
"pretty_assertions",
"similar",
"tempfile",
"thiserror 2.0.12",
"thiserror 2.0.16",
"tree-sitter",
"tree-sitter-bash",
]
@@ -748,7 +748,7 @@ dependencies = [
"similar",
"strum_macros 0.27.2",
"tempfile",
"thiserror 2.0.12",
"thiserror 2.0.16",
"time",
"tokio",
"tokio-test",
@@ -856,7 +856,7 @@ dependencies = [
"serde_json",
"sha2",
"tempfile",
"thiserror 2.0.12",
"thiserror 2.0.16",
"tiny_http",
"tokio",
"url",
@@ -2687,7 +2687,7 @@ checksum = "b3d2ef408b88e913bfc6594f5e693d57676f6463ded7d8bf994175364320c706"
dependencies = [
"enumflags2",
"libc",
"thiserror 2.0.12",
"thiserror 2.0.16",
]
[[package]]
@@ -3859,7 +3859,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b"
dependencies = [
"getrandom 0.2.16",
"libredox",
"thiserror 2.0.12",
"thiserror 2.0.16",
]
[[package]]
@@ -4944,11 +4944,11 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.12"
version = "2.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0"
dependencies = [
"thiserror-impl 2.0.12",
"thiserror-impl 2.0.16",
]
[[package]]
@@ -4964,9 +4964,9 @@ dependencies = [
[[package]]
name = "thiserror-impl"
version = "2.0.12"
version = "2.0.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960"
dependencies = [
"proc-macro2",
"quote",
@@ -5403,7 +5403,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ef1b7a6d914a34127ed8e1fa927eb7088903787bcded4fa3eef8f85ee1568be"
dependencies = [
"serde_json",
"thiserror 2.0.12",
"thiserror 2.0.16",
"ts-rs-macros",
"uuid",
]
+1 -1
View File
@@ -17,7 +17,7 @@ workspace = true
[dependencies]
anyhow = "1"
similar = "2.7.0"
thiserror = "2.0.12"
thiserror = "2.0.16"
tree-sitter = "0.25.8"
tree-sitter-bash = "0.25.0"
once_cell = "1"
+1 -1
View File
@@ -31,7 +31,7 @@ pub async fn run_apply_command(
ConfigOverrides::default(),
)?;
init_chatgpt_token_from_auth(&config.codex_home).await?;
init_chatgpt_token_from_auth(&config.codex_home, &config.responses_originator_header).await?;
let task_response = get_task(&config, apply_cli.task_id).await?;
apply_diff_from_task(task_response, cwd).await
+3 -4
View File
@@ -1,5 +1,5 @@
use codex_core::config::Config;
use codex_core::user_agent::get_codex_user_agent;
use codex_core::default_client::create_client;
use crate::chatgpt_token::get_chatgpt_token_data;
use crate::chatgpt_token::init_chatgpt_token_from_auth;
@@ -13,10 +13,10 @@ pub(crate) async fn chatgpt_get_request<T: DeserializeOwned>(
path: String,
) -> anyhow::Result<T> {
let chatgpt_base_url = &config.chatgpt_base_url;
init_chatgpt_token_from_auth(&config.codex_home).await?;
init_chatgpt_token_from_auth(&config.codex_home, &config.responses_originator_header).await?;
// Make direct HTTP request to ChatGPT backend API with the token
let client = reqwest::Client::new();
let client = create_client(&config.responses_originator_header);
let url = format!("{chatgpt_base_url}{path}");
let token =
@@ -31,7 +31,6 @@ pub(crate) async fn chatgpt_get_request<T: DeserializeOwned>(
.bearer_auth(&token.access_token)
.header("chatgpt-account-id", account_id?)
.header("Content-Type", "application/json")
.header("User-Agent", get_codex_user_agent(None))
.send()
.await
.context("Failed to send request")?;
+5 -2
View File
@@ -19,8 +19,11 @@ pub fn set_chatgpt_token_data(value: TokenData) {
}
/// Initialize the ChatGPT token from auth.json file
pub async fn init_chatgpt_token_from_auth(codex_home: &Path) -> std::io::Result<()> {
let auth = CodexAuth::from_codex_home(codex_home, AuthMode::ChatGPT)?;
pub async fn init_chatgpt_token_from_auth(
codex_home: &Path,
originator: &str,
) -> std::io::Result<()> {
let auth = CodexAuth::from_codex_home(codex_home, AuthMode::ChatGPT, originator)?;
if let Some(auth) = auth {
let token_data = auth.get_token_data().await?;
set_chatgpt_token_data(token_data);
+13 -4
View File
@@ -12,8 +12,8 @@ use codex_protocol::mcp_protocol::AuthMode;
use std::env;
use std::path::PathBuf;
pub async fn login_with_chatgpt(codex_home: PathBuf) -> std::io::Result<()> {
let opts = ServerOptions::new(codex_home, CLIENT_ID.to_string());
pub async fn login_with_chatgpt(codex_home: PathBuf, originator: String) -> std::io::Result<()> {
let opts = ServerOptions::new(codex_home, CLIENT_ID.to_string(), originator);
let server = run_login_server(opts)?;
eprintln!(
@@ -27,7 +27,12 @@ pub async fn login_with_chatgpt(codex_home: PathBuf) -> std::io::Result<()> {
pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! {
let config = load_config_or_exit(cli_config_overrides);
match login_with_chatgpt(config.codex_home).await {
match login_with_chatgpt(
config.codex_home,
config.responses_originator_header.clone(),
)
.await
{
Ok(_) => {
eprintln!("Successfully logged in");
std::process::exit(0);
@@ -60,7 +65,11 @@ pub async fn run_login_with_api_key(
pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
let config = load_config_or_exit(cli_config_overrides);
match CodexAuth::from_codex_home(&config.codex_home, config.preferred_auth_method) {
match CodexAuth::from_codex_home(
&config.codex_home,
config.preferred_auth_method,
&config.responses_originator_header,
) {
Ok(Some(auth)) => match auth.mode {
AuthMode::ApiKey => match auth.get_token().await {
Ok(api_key) => {
+1
View File
@@ -40,6 +40,7 @@ pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> {
let conversation_manager = ConversationManager::new(AuthManager::shared(
config.codex_home.clone(),
config.preferred_auth_method,
config.responses_originator_header.clone(),
));
let NewConversation {
conversation_id: _,
+2 -2
View File
@@ -40,8 +40,8 @@ shlex = "1.3.0"
similar = "2.7.0"
strum_macros = "0.27.2"
tempfile = "3"
thiserror = "2.0.12"
time = { version = "0.3", features = ["formatting", "local-offset", "macros"] }
thiserror = "2.0.16"
time = { version = "0.3", features = ["formatting", "parsing", "local-offset", "macros"] }
tokio = { version = "1", features = [
"io-std",
"macros",
-1
View File
@@ -228,7 +228,6 @@ You are producing plain text that will later be styled by the CLI. Follow these
**Bullets**
- Use `-` followed by a space for every bullet.
- Bold the keyword, then colon + concise description.
- Merge related points when possible; avoid a bullet for every trivial detail.
- Keep bullets to one line unless breaking for clarity is unavoidable.
- Group into short lists (4–6 bullets) ordered by importance.
+53 -26
View File
@@ -27,6 +27,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: reqwest::Client,
}
impl PartialEq for CodexAuth {
@@ -36,22 +37,13 @@ impl PartialEq for CodexAuth {
}
impl CodexAuth {
pub fn from_api_key(api_key: &str) -> Self {
Self {
api_key: Some(api_key.to_owned()),
mode: AuthMode::ApiKey,
auth_file: PathBuf::new(),
auth_dot_json: Arc::new(Mutex::new(None)),
}
}
pub async fn refresh_token(&self) -> Result<String, std::io::Error> {
let token_data = self
.get_current_token_data()
.ok_or(std::io::Error::other("Token data is not available."))?;
let token = token_data.refresh_token;
let refresh_response = try_refresh_token(token)
let refresh_response = try_refresh_token(token, &self.client)
.await
.map_err(std::io::Error::other)?;
@@ -83,8 +75,9 @@ impl CodexAuth {
pub fn from_codex_home(
codex_home: &Path,
preferred_auth_method: AuthMode,
originator: &str,
) -> std::io::Result<Option<CodexAuth>> {
load_auth(codex_home, true, preferred_auth_method)
load_auth(codex_home, true, preferred_auth_method, originator)
}
pub async fn get_token_data(&self) -> Result<TokenData, std::io::Error> {
@@ -98,7 +91,7 @@ impl CodexAuth {
if last_refresh < Utc::now() - chrono::Duration::days(28) {
let refresh_response = tokio::time::timeout(
Duration::from_secs(60),
try_refresh_token(tokens.refresh_token.clone()),
try_refresh_token(tokens.refresh_token.clone(), &self.client),
)
.await
.map_err(|_| {
@@ -180,8 +173,26 @@ impl CodexAuth {
mode: AuthMode::ChatGPT,
auth_file: PathBuf::new(),
auth_dot_json,
client: crate::default_client::create_client("codex_cli_rs"),
}
}
fn from_api_key_with_client(api_key: &str, client: reqwest::Client) -> Self {
Self {
api_key: Some(api_key.to_owned()),
mode: AuthMode::ApiKey,
auth_file: PathBuf::new(),
auth_dot_json: Arc::new(Mutex::new(None)),
client,
}
}
pub fn from_api_key(api_key: &str) -> Self {
Self::from_api_key_with_client(
api_key,
crate::default_client::create_client(crate::default_client::DEFAULT_ORIGINATOR),
)
}
}
pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY";
@@ -221,18 +232,20 @@ fn load_auth(
codex_home: &Path,
include_env_var: bool,
preferred_auth_method: AuthMode,
originator: &str,
) -> std::io::Result<Option<CodexAuth>> {
// First, check to see if there is a valid auth.json file. If not, we fall
// back to AuthMode::ApiKey using the OPENAI_API_KEY environment variable
// (if it is set).
let auth_file = get_auth_file(codex_home);
let client = crate::default_client::create_client(originator);
let auth_dot_json = match try_read_auth_json(&auth_file) {
Ok(auth) => auth,
// If auth.json does not exist, try to read the OPENAI_API_KEY from the
// environment variable.
Err(e) if e.kind() == std::io::ErrorKind::NotFound && include_env_var => {
return match read_openai_api_key_from_env() {
Some(api_key) => Ok(Some(CodexAuth::from_api_key(&api_key))),
Some(api_key) => Ok(Some(CodexAuth::from_api_key_with_client(&api_key, client))),
None => Ok(None),
};
}
@@ -258,7 +271,7 @@ fn load_auth(
match &tokens {
Some(tokens) => {
if tokens.should_use_api_key(preferred_auth_method, tokens.is_openai_email()) {
return Ok(Some(CodexAuth::from_api_key(api_key)));
return Ok(Some(CodexAuth::from_api_key_with_client(api_key, client)));
} else {
// Ignore the API key and fall through to ChatGPT auth.
}
@@ -268,7 +281,7 @@ fn load_auth(
// Perhaps the user ran `codex login --api-key <KEY>` or updated
// auth.json by hand. Either way, let's assume they are trying
// to use their API key.
return Ok(Some(CodexAuth::from_api_key(api_key)));
return Ok(Some(CodexAuth::from_api_key_with_client(api_key, client)));
}
}
}
@@ -284,6 +297,7 @@ fn load_auth(
tokens,
last_refresh,
}))),
client,
}))
}
@@ -333,7 +347,10 @@ async fn update_tokens(
Ok(auth_dot_json)
}
async fn try_refresh_token(refresh_token: String) -> std::io::Result<RefreshResponse> {
async fn try_refresh_token(
refresh_token: String,
client: &reqwest::Client,
) -> std::io::Result<RefreshResponse> {
let refresh_request = RefreshRequest {
client_id: CLIENT_ID,
grant_type: "refresh_token",
@@ -341,7 +358,7 @@ async fn try_refresh_token(refresh_token: String) -> std::io::Result<RefreshResp
scope: "openid profile email",
};
let client = reqwest::Client::new();
// Use shared client factory to include standard headers
let response = client
.post("https://auth.openai.com/oauth/token")
.header("Content-Type", "application/json")
@@ -455,7 +472,8 @@ mod tests {
mode,
auth_dot_json,
auth_file: _,
} = super::load_auth(codex_home.path(), false, AuthMode::ChatGPT)
..
} = super::load_auth(codex_home.path(), false, AuthMode::ChatGPT, "codex_cli_rs")
.unwrap()
.unwrap();
assert_eq!(None, api_key);
@@ -506,7 +524,8 @@ mod tests {
mode,
auth_dot_json,
auth_file: _,
} = super::load_auth(codex_home.path(), false, AuthMode::ChatGPT)
..
} = super::load_auth(codex_home.path(), false, AuthMode::ChatGPT, "codex_cli_rs")
.unwrap()
.unwrap();
assert_eq!(None, api_key);
@@ -556,7 +575,8 @@ mod tests {
mode,
auth_dot_json,
auth_file: _,
} = super::load_auth(codex_home.path(), false, AuthMode::ChatGPT)
..
} = super::load_auth(codex_home.path(), false, AuthMode::ChatGPT, "codex_cli_rs")
.unwrap()
.unwrap();
assert_eq!(Some("sk-test-key".to_string()), api_key);
@@ -576,7 +596,7 @@ mod tests {
)
.unwrap();
let auth = super::load_auth(dir.path(), false, AuthMode::ChatGPT)
let auth = super::load_auth(dir.path(), false, AuthMode::ChatGPT, "codex_cli_rs")
.unwrap()
.unwrap();
assert_eq!(auth.mode, AuthMode::ApiKey);
@@ -660,6 +680,7 @@ mod tests {
#[derive(Debug)]
pub struct AuthManager {
codex_home: PathBuf,
originator: String,
inner: RwLock<CachedAuth>,
}
@@ -668,12 +689,13 @@ impl AuthManager {
/// preferred auth method. Errors loading auth are swallowed; `auth()` will
/// simply return `None` in that case so callers can treat it as an
/// unauthenticated state.
pub fn new(codex_home: PathBuf, preferred_auth_mode: AuthMode) -> Self {
let auth = CodexAuth::from_codex_home(&codex_home, preferred_auth_mode)
pub fn new(codex_home: PathBuf, preferred_auth_mode: AuthMode, originator: String) -> Self {
let auth = CodexAuth::from_codex_home(&codex_home, preferred_auth_mode, &originator)
.ok()
.flatten();
Self {
codex_home,
originator,
inner: RwLock::new(CachedAuth {
preferred_auth_mode,
auth,
@@ -690,6 +712,7 @@ impl AuthManager {
};
Arc::new(Self {
codex_home: PathBuf::new(),
originator: "codex_cli_rs".to_string(),
inner: RwLock::new(cached),
})
}
@@ -711,7 +734,7 @@ impl AuthManager {
/// whether the auth value changed.
pub fn reload(&self) -> bool {
let preferred = self.preferred_auth_method();
let new_auth = CodexAuth::from_codex_home(&self.codex_home, preferred)
let new_auth = CodexAuth::from_codex_home(&self.codex_home, preferred, &self.originator)
.ok()
.flatten();
if let Ok(mut guard) = self.inner.write() {
@@ -732,8 +755,12 @@ impl AuthManager {
}
/// Convenience constructor returning an `Arc` wrapper.
pub fn shared(codex_home: PathBuf, preferred_auth_mode: AuthMode) -> Arc<Self> {
Arc::new(Self::new(codex_home, preferred_auth_mode))
pub fn shared(
codex_home: PathBuf,
preferred_auth_mode: AuthMode,
originator: String,
) -> Arc<Self> {
Arc::new(Self::new(codex_home, preferred_auth_mode, originator))
}
/// Attempt to refresh the current auth token (if any). On success, reload
+4 -6
View File
@@ -30,6 +30,7 @@ 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::create_client;
use crate::error::CodexErr;
use crate::error::Result;
use crate::error::UsageLimitReachedError;
@@ -40,7 +41,6 @@ use crate::model_provider_info::WireApi;
use crate::openai_model_info::get_model_info;
use crate::openai_tools::create_tools_json_for_responses_api;
use crate::protocol::TokenUsage;
use crate::user_agent::get_codex_user_agent;
use crate::util::backoff;
use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
@@ -84,10 +84,12 @@ impl ModelClient {
summary: ReasoningSummaryConfig,
session_id: Uuid,
) -> Self {
let client = create_client(&config.responses_originator_header);
Self {
config,
auth_manager,
client: reqwest::Client::new(),
client,
provider,
session_id,
effort,
@@ -242,10 +244,6 @@ impl ModelClient {
req_builder = req_builder.header("chatgpt-account-id", account_id);
}
let originator = &self.config.responses_originator_header;
req_builder = req_builder.header("originator", originator);
req_builder = req_builder.header("User-Agent", get_codex_user_agent(Some(originator)));
let res = req_builder.send().await;
if let Ok(resp) = &res {
trace!(
+1 -1
View File
@@ -1,4 +1,3 @@
use crate::config_types::Verbosity as VerbosityConfig;
use crate::error::Result;
use crate::model_family::ModelFamily;
use crate::openai_tools::OpenAiTool;
@@ -6,6 +5,7 @@ use crate::protocol::TokenUsage;
use codex_apply_patch::APPLY_PATCH_TOOL_INSTRUCTIONS;
use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
use codex_protocol::config_types::Verbosity as VerbosityConfig;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use futures::Stream;
+22 -10
View File
@@ -568,9 +568,19 @@ impl Session {
cwd: PathBuf,
reason: Option<String>,
) -> oneshot::Receiver<ReviewDecision> {
// Add the tx_approve callback to the map before sending the request.
let (tx_approve, rx_approve) = oneshot::channel();
let event_id = sub_id.clone();
let prev_entry = {
let mut state = self.state.lock_unchecked();
state.pending_approvals.insert(sub_id, tx_approve)
};
if prev_entry.is_some() {
warn!("Overwriting existing pending approval for sub_id: {event_id}");
}
let event = Event {
id: sub_id.clone(),
id: event_id,
msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
call_id,
command,
@@ -579,10 +589,6 @@ impl Session {
}),
};
let _ = self.tx_event.send(event).await;
{
let mut state = self.state.lock_unchecked();
state.pending_approvals.insert(sub_id, tx_approve);
}
rx_approve
}
@@ -594,9 +600,19 @@ impl Session {
reason: Option<String>,
grant_root: Option<PathBuf>,
) -> oneshot::Receiver<ReviewDecision> {
// Add the tx_approve callback to the map before sending the request.
let (tx_approve, rx_approve) = oneshot::channel();
let event_id = sub_id.clone();
let prev_entry = {
let mut state = self.state.lock_unchecked();
state.pending_approvals.insert(sub_id, tx_approve)
};
if prev_entry.is_some() {
warn!("Overwriting existing pending approval for sub_id: {event_id}");
}
let event = Event {
id: sub_id.clone(),
id: event_id,
msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
call_id,
changes: convert_apply_patch_to_protocol(action),
@@ -605,10 +621,6 @@ impl Session {
}),
};
let _ = self.tx_event.send(event).await;
{
let mut state = self.state.lock_unchecked();
state.pending_approvals.insert(sub_id, tx_approve);
}
rx_approve
}
+69 -1
View File
@@ -6,7 +6,6 @@ use crate::config_types::ShellEnvironmentPolicy;
use crate::config_types::ShellEnvironmentPolicyToml;
use crate::config_types::Tui;
use crate::config_types::UriBasedFileOpener;
use crate::config_types::Verbosity;
use crate::git_info::resolve_root_git_project_for_trust;
use crate::model_family::ModelFamily;
use crate::model_family::find_family_for_model;
@@ -18,6 +17,7 @@ use crate::protocol::SandboxPolicy;
use codex_protocol::config_types::ReasoningEffort;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::Verbosity;
use codex_protocol::mcp_protocol::AuthMode;
use dirs::home_dir;
use serde::Deserialize;
@@ -1067,6 +1067,14 @@ model = "o3"
model_provider = "openai"
approval_policy = "on-failure"
disable_response_storage = true
[profiles.gpt5]
model = "gpt-5"
model_provider = "openai"
approval_policy = "on-failure"
model_reasoning_effort = "high"
model_reasoning_summary = "detailed"
model_verbosity = "high"
"#;
let cfg: ConfigToml = toml::from_str(toml).expect("TOML deserialization should succeed");
@@ -1325,6 +1333,66 @@ disable_response_storage = true
Ok(())
}
#[test]
fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
let fixture = create_test_fixture()?;
let gpt5_profile_overrides = ConfigOverrides {
config_profile: Some("gpt5".to_string()),
cwd: Some(fixture.cwd()),
..Default::default()
};
let gpt5_profile_config = Config::load_from_base_config_with_overrides(
fixture.cfg.clone(),
gpt5_profile_overrides,
fixture.codex_home(),
)?;
let expected_gpt5_profile_config = Config {
model: "gpt-5".to_string(),
model_family: find_family_for_model("gpt-5").expect("known model slug"),
model_context_window: Some(400_000),
model_max_output_tokens: Some(128_000),
model_provider_id: "openai".to_string(),
model_provider: fixture.openai_provider.clone(),
approval_policy: AskForApproval::OnFailure,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
shell_environment_policy: ShellEnvironmentPolicy::default(),
disable_response_storage: false,
user_instructions: None,
notify: None,
cwd: fixture.cwd(),
mcp_servers: HashMap::new(),
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
codex_home: fixture.codex_home(),
history: History::default(),
file_opener: UriBasedFileOpener::VsCode,
tui: Tui::default(),
codex_linux_sandbox_exe: None,
hide_agent_reasoning: false,
show_raw_agent_reasoning: false,
model_reasoning_effort: ReasoningEffort::High,
model_reasoning_summary: ReasoningSummary::Detailed,
model_verbosity: Some(Verbosity::High),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
experimental_resume: None,
base_instructions: None,
include_plan_tool: false,
include_apply_patch_tool: false,
tools_web_search_request: false,
responses_originator_header: "codex_cli_rs".to_string(),
preferred_auth_method: AuthMode::ChatGPT,
use_experimental_streamable_shell_tool: false,
include_view_image_tool: true,
disable_paste_burst: false,
use_experimental_reasoning_summary: false,
};
assert_eq!(expected_gpt5_profile_config, gpt5_profile_config);
Ok(())
}
#[test]
fn test_set_project_trusted_writes_explicit_tables() -> anyhow::Result<()> {
let codex_home = TempDir::new().unwrap();
+1 -1
View File
@@ -1,10 +1,10 @@
use serde::Deserialize;
use std::path::PathBuf;
use crate::config_types::Verbosity;
use crate::protocol::AskForApproval;
use codex_protocol::config_types::ReasoningEffort;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::Verbosity;
/// Collection of common configuration options that a user can define as a unit
/// in `config.toml`.
-42
View File
@@ -8,8 +8,6 @@ use std::path::PathBuf;
use wildmatch::WildMatchPattern;
use serde::Deserialize;
use serde::Serialize;
use strum_macros::Display;
#[derive(Deserialize, Debug, Clone, PartialEq)]
pub struct McpServerConfig {
@@ -185,43 +183,3 @@ impl From<ShellEnvironmentPolicyToml> for ShellEnvironmentPolicy {
}
}
}
/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning
#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum ReasoningEffort {
Low,
#[default]
Medium,
High,
/// Option to disable reasoning.
None,
}
/// A summary of the reasoning performed by the model. This can be useful for
/// debugging and understanding the model's reasoning process.
/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries
#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum ReasoningSummary {
#[default]
Auto,
Concise,
Detailed,
/// Option to disable reasoning summaries.
None,
}
/// Controls output length/detail on GPT-5 models via the Responses API.
/// Serialized with lowercase values to match the OpenAI API.
#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum Verbosity {
Low,
#[default]
Medium,
High,
}
+106
View File
@@ -0,0 +1,106 @@
pub const DEFAULT_ORIGINATOR: &str = "codex_cli_rs";
pub fn get_codex_user_agent(originator: Option<&str>) -> String {
let build_version = env!("CARGO_PKG_VERSION");
let os_info = os_info::get();
format!(
"{}/{build_version} ({} {}; {}) {}",
originator.unwrap_or(DEFAULT_ORIGINATOR),
os_info.os_type(),
os_info.version(),
os_info.architecture().unwrap_or("unknown"),
crate::terminal::user_agent()
)
}
/// Create a reqwest client with default `originator` and `User-Agent` headers set.
pub fn create_client(originator: &str) -> reqwest::Client {
use reqwest::header::HeaderMap;
use reqwest::header::HeaderValue;
let mut headers = HeaderMap::new();
let originator_value = HeaderValue::from_str(originator)
.unwrap_or_else(|_| HeaderValue::from_static(DEFAULT_ORIGINATOR));
headers.insert("originator", originator_value);
let ua = get_codex_user_agent(Some(originator));
match reqwest::Client::builder()
// Set UA via dedicated helper to avoid header validation pitfalls
.user_agent(ua)
.default_headers(headers)
.build()
{
Ok(client) => client,
Err(_) => reqwest::Client::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_codex_user_agent() {
let user_agent = get_codex_user_agent(None);
assert!(user_agent.starts_with("codex_cli_rs/"));
}
#[tokio::test]
async fn test_create_client_sets_default_headers() {
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
let originator = "test_originator";
let client = create_client(originator);
// Spin up a local mock server and capture a request.
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let resp = client
.get(server.uri())
.send()
.await
.expect("failed to send request");
assert!(resp.status().is_success());
let requests = server
.received_requests()
.await
.expect("failed to fetch received requests");
assert!(!requests.is_empty());
let headers = &requests[0].headers;
// originator header is set to the provided value
let originator_header = headers
.get("originator")
.expect("originator header missing");
assert_eq!(originator_header.to_str().unwrap(), originator);
// User-Agent matches the computed Codex UA for that originator
let expected_ua = get_codex_user_agent(Some(originator));
let ua_header = headers
.get("user-agent")
.expect("user-agent header missing");
assert_eq!(ua_header.to_str().unwrap(), expected_ua);
}
#[test]
#[cfg(target_os = "macos")]
fn test_macos() {
use regex_lite::Regex;
let user_agent = get_codex_user_agent(None);
let re = Regex::new(
r"^codex_cli_rs/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\) (\S+)$",
)
.unwrap();
assert!(re.is_match(&user_agent));
}
}
+2 -1
View File
@@ -45,6 +45,7 @@ pub use conversation_manager::NewConversation;
// Re-export common auth types for workspace consumers
pub use auth::AuthManager;
pub use auth::CodexAuth;
pub mod default_client;
pub mod model_family;
mod openai_model_info;
mod openai_tools;
@@ -58,7 +59,7 @@ pub mod spawn;
pub mod terminal;
mod tool_apply_patch;
pub mod turn_diff_tracker;
pub mod user_agent;
pub use rollout::list::ConversationsPage;
mod user_notification;
pub mod util;
pub use apply_patch::CODEX_APPLY_PATCH_ARG1;
+54 -74
View File
@@ -105,47 +105,34 @@ pub(crate) async fn append_entry(text: &str, session_id: &Uuid, config: &Config)
// Ensure permissions.
ensure_owner_only_permissions(&history_file).await?;
// Lock file.
acquire_exclusive_lock_with_retry(&history_file).await?;
// We use sync I/O with spawn_blocking() because we are using a
// [`std::fs::File`] instead of a [`tokio::fs::File`] to leverage an
// advisory file locking API that is not available in the async API.
// Perform a blocking write under an advisory write lock using std::fs.
tokio::task::spawn_blocking(move || -> Result<()> {
history_file.write_all(line.as_bytes())?;
history_file.flush()?;
Ok(())
// Retry a few times to avoid indefinite blocking when contended.
for _ in 0..MAX_RETRIES {
match history_file.try_lock() {
Ok(()) => {
// While holding the exclusive lock, write the full line.
history_file.write_all(line.as_bytes())?;
history_file.flush()?;
return Ok(());
}
Err(std::fs::TryLockError::WouldBlock) => {
std::thread::sleep(RETRY_SLEEP);
}
Err(e) => return Err(e.into()),
}
}
Err(std::io::Error::new(
std::io::ErrorKind::WouldBlock,
"could not acquire exclusive lock on history file after multiple attempts",
))
})
.await??;
Ok(())
}
/// Attempt to acquire an exclusive advisory lock on `file`, retrying up to 10
/// times if the lock is currently held by another process. This prevents a
/// potential indefinite wait while still giving other writers some time to
/// finish their operation.
async fn acquire_exclusive_lock_with_retry(file: &File) -> Result<()> {
use tokio::time::sleep;
for _ in 0..MAX_RETRIES {
match file.try_lock() {
Ok(()) => return Ok(()),
Err(e) => match e {
std::fs::TryLockError::WouldBlock => {
sleep(RETRY_SLEEP).await;
}
other => return Err(other.into()),
},
}
}
Err(std::io::Error::new(
std::io::ErrorKind::WouldBlock,
"could not acquire exclusive lock on history file after multiple attempts",
))
}
/// Asynchronously fetch the history file's *identifier* (inode on Unix) and
/// the current number of entries by counting newline characters.
pub(crate) async fn history_metadata(config: &Config) -> (u64, usize) {
@@ -221,29 +208,42 @@ pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option<Hist
return None;
}
// Open & lock file for reading.
if let Err(e) = acquire_shared_lock_with_retry(&file) {
tracing::warn!(error = %e, "failed to acquire shared lock on history file");
return None;
}
// Open & lock file for reading using a shared lock.
// Retry a few times to avoid indefinite blocking.
for _ in 0..MAX_RETRIES {
let lock_result = file.try_lock_shared();
let reader = BufReader::new(&file);
for (idx, line_res) in reader.lines().enumerate() {
let line = match line_res {
Ok(l) => l,
Err(e) => {
tracing::warn!(error = %e, "failed to read line from history file");
match lock_result {
Ok(()) => {
let reader = BufReader::new(&file);
for (idx, line_res) in reader.lines().enumerate() {
let line = match line_res {
Ok(l) => l,
Err(e) => {
tracing::warn!(error = %e, "failed to read line from history file");
return None;
}
};
if idx == offset {
match serde_json::from_str::<HistoryEntry>(&line) {
Ok(entry) => return Some(entry),
Err(e) => {
tracing::warn!(error = %e, "failed to parse history entry");
return None;
}
}
}
}
// Not found at requested offset.
return None;
}
};
if idx == offset {
match serde_json::from_str::<HistoryEntry>(&line) {
Ok(entry) => return Some(entry),
Err(e) => {
tracing::warn!(error = %e, "failed to parse history entry");
return None;
}
Err(std::fs::TryLockError::WouldBlock) => {
std::thread::sleep(RETRY_SLEEP);
}
Err(e) => {
tracing::warn!(error = %e, "failed to acquire shared lock on history file");
return None;
}
}
}
@@ -258,26 +258,6 @@ pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option<Hist
None
}
#[cfg(unix)]
fn acquire_shared_lock_with_retry(file: &File) -> Result<()> {
for _ in 0..MAX_RETRIES {
match file.try_lock_shared() {
Ok(()) => return Ok(()),
Err(e) => match e {
std::fs::TryLockError::WouldBlock => {
std::thread::sleep(RETRY_SLEEP);
}
other => return Err(other.into()),
},
}
}
Err(std::io::Error::new(
std::io::ErrorKind::WouldBlock,
"could not acquire shared lock on history file after multiple attempts",
))
}
/// On Unix systems ensure the file permissions are `0o600` (rw-------). If the
/// permissions cannot be changed the error is propagated to the caller.
#[cfg(unix)]
+89 -7
View File
@@ -49,7 +49,7 @@ pub(crate) enum OpenAiTool {
LocalShell {},
// TODO: Understand why we get an error on web_search although the API docs say it's supported.
// https://platform.openai.com/docs/guides/tools-web-search?api-mode=responses#:~:text=%7B%20type%3A%20%22web_search%22%20%7D%2C
#[serde(rename = "web_search_preview")]
#[serde(rename = "web_search")]
WebSearch {},
#[serde(rename = "custom")]
Freeform(FreeformTool),
@@ -240,15 +240,17 @@ fn create_shell_tool_for_sandbox(sandbox_policy: &SandboxPolicy) -> OpenAiTool {
let description = match sandbox_policy {
SandboxPolicy::WorkspaceWrite {
network_access,
writable_roots,
..
} => {
format!(
r#"
The shell tool is used to execute shell commands.
- When invoking the shell tool, your call will be running in a landlock sandbox, and some shell commands will require escalated privileges:
- When invoking the shell tool, your call will be running in a sandbox, and some shell commands will require escalated privileges:
- Types of actions that require escalated privileges:
- Reading files outside the current directory
- Writing files outside the current directory, and protected folders like .git or .env{}
- Writing files other than those in the writable roots
- writable roots:
{}{}
- Examples of commands that require escalated privileges:
- git commit
- npm install or pnpm install
@@ -257,8 +259,9 @@ The shell tool is used to execute shell commands.
- When invoking a command that will require escalated privileges:
- Provide the with_escalated_permissions parameter with the boolean value true
- Include a short, 1 sentence explanation for why we need to run with_escalated_permissions in the justification parameter."#,
writable_roots.iter().map(|wr| format!(" - {}", wr.to_string_lossy())).collect::<Vec<String>>().join("\n"),
if !network_access {
"\n - Commands that require network access\n"
"\n - Commands that require network access\n"
} else {
""
}
@@ -270,9 +273,8 @@ The shell tool is used to execute shell commands.
SandboxPolicy::ReadOnly => {
r#"
The shell tool is used to execute shell commands.
- When invoking the shell tool, your call will be running in a landlock sandbox, and some shell commands (including apply_patch) will require escalated permissions:
- When invoking the shell tool, your call will be running in a sandbox, and some shell commands (including apply_patch) will require escalated permissions:
- Types of actions that require escalated privileges:
- Reading files outside the current directory
- Writing files
- Applying patches
- Examples of commands that require escalated privileges:
@@ -1081,4 +1083,84 @@ mod tests {
})
);
}
#[test]
fn test_shell_tool_for_sandbox_workspace_write() {
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
writable_roots: vec!["workspace".into()],
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
};
let tool = super::create_shell_tool_for_sandbox(&sandbox_policy);
let OpenAiTool::Function(ResponsesApiTool {
description, name, ..
}) = &tool
else {
panic!("expected function tool");
};
assert_eq!(name, "shell");
let expected = r#"
The shell tool is used to execute shell commands.
- When invoking the shell tool, your call will be running in a sandbox, and some shell commands will require escalated privileges:
- Types of actions that require escalated privileges:
- Writing files other than those in the writable roots
- writable roots:
- workspace
- Commands that require network access
- Examples of commands that require escalated privileges:
- git commit
- npm install or pnpm install
- cargo build
- cargo test
- When invoking a command that will require escalated privileges:
- Provide the with_escalated_permissions parameter with the boolean value true
- Include a short, 1 sentence explanation for why we need to run with_escalated_permissions in the justification parameter."#;
assert_eq!(description, expected);
}
#[test]
fn test_shell_tool_for_sandbox_readonly() {
let tool = super::create_shell_tool_for_sandbox(&SandboxPolicy::ReadOnly);
let OpenAiTool::Function(ResponsesApiTool {
description, name, ..
}) = &tool
else {
panic!("expected function tool");
};
assert_eq!(name, "shell");
let expected = r#"
The shell tool is used to execute shell commands.
- When invoking the shell tool, your call will be running in a sandbox, and some shell commands (including apply_patch) will require escalated permissions:
- Types of actions that require escalated privileges:
- Writing files
- Applying patches
- Examples of commands that require escalated privileges:
- apply_patch
- git commit
- npm install or pnpm install
- cargo build
- cargo test
- When invoking a command that will require escalated privileges:
- Provide the with_escalated_permissions parameter with the boolean value true
- Include a short, 1 sentence explanation for why we need to run with_escalated_permissions in the justification parameter"#;
assert_eq!(description, expected);
}
#[test]
fn test_shell_tool_for_sandbox_danger_full_access() {
let tool = super::create_shell_tool_for_sandbox(&SandboxPolicy::DangerFullAccess);
let OpenAiTool::Function(ResponsesApiTool {
description, name, ..
}) = &tool
else {
panic!("expected function tool");
};
assert_eq!(name, "shell");
assert_eq!(description, "Runs a shell command and returns its output.");
}
}
+295
View File
@@ -0,0 +1,295 @@
use std::cmp::Reverse;
use std::io::{self};
use std::path::Path;
use std::path::PathBuf;
use time::OffsetDateTime;
use time::PrimitiveDateTime;
use time::format_description::FormatItem;
use time::macros::format_description;
use uuid::Uuid;
use super::SESSIONS_SUBDIR;
/// Returned page of conversation summaries.
#[derive(Debug, Default, PartialEq)]
pub struct ConversationsPage {
/// Conversation summaries ordered newest first.
pub items: Vec<ConversationItem>,
/// Opaque pagination token to resume after the last item, or `None` if end.
pub next_cursor: Option<Cursor>,
/// Total number of files touched while scanning this request.
pub num_scanned_files: usize,
/// True if a hard scan cap was hit; consider resuming with `next_cursor`.
pub reached_scan_cap: bool,
}
/// Summary information for a conversation rollout file.
#[derive(Debug, PartialEq)]
pub struct ConversationItem {
/// Absolute path to the rollout file.
pub path: PathBuf,
/// First up to 5 JSONL records parsed as JSON (includes meta line).
pub head: Vec<serde_json::Value>,
}
/// Hard cap to bound worst‑case work per request.
const MAX_SCAN_FILES: usize = 50_000;
/// Pagination cursor identifying a file by timestamp and UUID.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cursor {
ts: OffsetDateTime,
id: Uuid,
}
impl Cursor {
fn new(ts: OffsetDateTime, id: Uuid) -> Self {
Self { ts, id }
}
}
impl serde::Serialize for Cursor {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let ts_str = self
.ts
.format(&format_description!(
"[year]-[month]-[day]T[hour]-[minute]-[second]"
))
.map_err(|e| serde::ser::Error::custom(format!("format error: {e}")))?;
serializer.serialize_str(&format!("{ts_str}|{}", self.id))
}
}
impl<'de> serde::Deserialize<'de> for Cursor {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
parse_cursor(&s).ok_or_else(|| serde::de::Error::custom("invalid cursor"))
}
}
/// Retrieve recorded conversation file paths with token pagination. The returned `next_cursor`
/// can be supplied on the next call to resume after the last returned item, resilient to
/// concurrent new sessions being appended. Ordering is stable by timestamp desc, then UUID desc.
pub(crate) async fn get_conversations(
codex_home: &Path,
page_size: usize,
cursor: Option<&Cursor>,
) -> io::Result<ConversationsPage> {
let mut root = codex_home.to_path_buf();
root.push(SESSIONS_SUBDIR);
if !root.exists() {
return Ok(ConversationsPage {
items: Vec::new(),
next_cursor: None,
num_scanned_files: 0,
reached_scan_cap: false,
});
}
let anchor = cursor.cloned();
let result = traverse_directories_for_paths(root.clone(), page_size, anchor).await?;
Ok(result)
}
/// Load the full contents of a single conversation session file at `path`.
/// Returns the entire file contents as a String.
#[allow(dead_code)]
pub(crate) async fn get_conversation(path: &Path) -> io::Result<String> {
tokio::fs::read_to_string(path).await
}
/// Load conversation file paths from disk using directory traversal.
///
/// Directory layout: `~/.codex/sessions/YYYY/MM/DD/rollout-YYYY-MM-DDThh-mm-ss-<uuid>.jsonl`
/// Returned newest (latest) first.
async fn traverse_directories_for_paths(
root: PathBuf,
page_size: usize,
anchor: Option<Cursor>,
) -> io::Result<ConversationsPage> {
let mut items: Vec<ConversationItem> = Vec::with_capacity(page_size);
let mut scanned_files = 0usize;
let mut anchor_passed = anchor.is_none();
let (anchor_ts, anchor_id) = match anchor {
Some(c) => (c.ts, c.id),
None => (OffsetDateTime::UNIX_EPOCH, Uuid::nil()),
};
let year_dirs = collect_dirs_desc(&root, |s| s.parse::<u16>().ok()).await?;
'outer: for (_year, year_path) in year_dirs.iter() {
if scanned_files >= MAX_SCAN_FILES {
break;
}
let month_dirs = collect_dirs_desc(year_path, |s| s.parse::<u8>().ok()).await?;
for (_month, month_path) in month_dirs.iter() {
if scanned_files >= MAX_SCAN_FILES {
break 'outer;
}
let day_dirs = collect_dirs_desc(month_path, |s| s.parse::<u8>().ok()).await?;
for (_day, day_path) in day_dirs.iter() {
if scanned_files >= MAX_SCAN_FILES {
break 'outer;
}
let mut day_files = collect_files(day_path, |name_str, path| {
if !name_str.starts_with("rollout-") || !name_str.ends_with(".jsonl") {
return None;
}
parse_timestamp_uuid_from_filename(name_str)
.map(|(ts, id)| (ts, id, name_str.to_string(), path.to_path_buf()))
})
.await?;
// Stable ordering within the same second: (timestamp desc, uuid desc)
day_files.sort_by_key(|(ts, sid, _name_str, _path)| (Reverse(*ts), Reverse(*sid)));
for (ts, sid, _name_str, path) in day_files.into_iter() {
scanned_files += 1;
if scanned_files >= MAX_SCAN_FILES && items.len() >= page_size {
break 'outer;
}
if !anchor_passed {
if ts < anchor_ts || (ts == anchor_ts && sid < anchor_id) {
anchor_passed = true;
} else {
continue;
}
}
if items.len() == page_size {
break 'outer;
}
let head = read_first_jsonl_records(&path, 5).await.unwrap_or_default();
items.push(ConversationItem { path, head });
}
}
}
}
let next = build_next_cursor(&items);
Ok(ConversationsPage {
items,
next_cursor: next,
num_scanned_files: scanned_files,
reached_scan_cap: scanned_files >= MAX_SCAN_FILES,
})
}
/// Pagination cursor token format: "<file_ts>|<uuid>" where `file_ts` matches the
/// filename timestamp portion (YYYY-MM-DDThh-mm-ss) used in rollout filenames.
/// The cursor orders files by timestamp desc, then UUID desc.
fn parse_cursor(token: &str) -> Option<Cursor> {
let (file_ts, uuid_str) = token.split_once('|')?;
let Ok(uuid) = Uuid::parse_str(uuid_str) else {
return None;
};
let format: &[FormatItem] =
format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]");
let ts = PrimitiveDateTime::parse(file_ts, format).ok()?.assume_utc();
Some(Cursor::new(ts, uuid))
}
fn build_next_cursor(items: &[ConversationItem]) -> Option<Cursor> {
let last = items.last()?;
let file_name = last.path.file_name()?.to_string_lossy();
let (ts, id) = parse_timestamp_uuid_from_filename(&file_name)?;
Some(Cursor::new(ts, id))
}
/// Collects immediate subdirectories of `parent`, parses their (string) names with `parse`,
/// and returns them sorted descending by the parsed key.
async fn collect_dirs_desc<T, F>(parent: &Path, parse: F) -> io::Result<Vec<(T, PathBuf)>>
where
T: Ord + Copy,
F: Fn(&str) -> Option<T>,
{
let mut dir = tokio::fs::read_dir(parent).await?;
let mut vec: Vec<(T, PathBuf)> = Vec::new();
while let Some(entry) = dir.next_entry().await? {
if entry
.file_type()
.await
.map(|ft| ft.is_dir())
.unwrap_or(false)
&& let Some(s) = entry.file_name().to_str()
&& let Some(v) = parse(s)
{
vec.push((v, entry.path()));
}
}
vec.sort_by_key(|(v, _)| Reverse(*v));
Ok(vec)
}
/// Collects files in a directory and parses them with `parse`.
async fn collect_files<T, F>(parent: &Path, parse: F) -> io::Result<Vec<T>>
where
F: Fn(&str, &Path) -> Option<T>,
{
let mut dir = tokio::fs::read_dir(parent).await?;
let mut collected: Vec<T> = Vec::new();
while let Some(entry) = dir.next_entry().await? {
if entry
.file_type()
.await
.map(|ft| ft.is_file())
.unwrap_or(false)
&& let Some(s) = entry.file_name().to_str()
&& let Some(v) = parse(s, &entry.path())
{
collected.push(v);
}
}
Ok(collected)
}
fn parse_timestamp_uuid_from_filename(name: &str) -> Option<(OffsetDateTime, Uuid)> {
// Expected: rollout-YYYY-MM-DDThh-mm-ss-<uuid>.jsonl
let core = name.strip_prefix("rollout-")?.strip_suffix(".jsonl")?;
// Scan from the right for a '-' such that the suffix parses as a UUID.
let (sep_idx, uuid) = core
.match_indices('-')
.rev()
.find_map(|(i, _)| Uuid::parse_str(&core[i + 1..]).ok().map(|u| (i, u)))?;
let ts_str = &core[..sep_idx];
let format: &[FormatItem] =
format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]");
let ts = PrimitiveDateTime::parse(ts_str, format).ok()?.assume_utc();
Some((ts, uuid))
}
async fn read_first_jsonl_records(
path: &Path,
max_records: usize,
) -> io::Result<Vec<serde_json::Value>> {
use tokio::io::AsyncBufReadExt;
let file = tokio::fs::File::open(path).await?;
let reader = tokio::io::BufReader::new(file);
let mut lines = reader.lines();
let mut head: Vec<serde_json::Value> = Vec::new();
while head.len() < max_records {
let line_opt = lines.next_line().await?;
let Some(line) = line_opt else { break };
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) {
head.push(v);
}
}
Ok(head)
}
+13
View File
@@ -0,0 +1,13 @@
//! Rollout module: persistence and discovery of session rollout files.
pub(crate) const SESSIONS_SUBDIR: &str = "sessions";
pub mod list;
pub(crate) mod policy;
pub mod recorder;
pub use recorder::RolloutRecorder;
pub use recorder::SessionStateSnapshot;
#[cfg(test)]
pub mod tests;
+16
View File
@@ -0,0 +1,16 @@
use codex_protocol::models::ResponseItem;
/// Whether a `ResponseItem` should be persisted in rollout files.
#[inline]
pub(crate) fn is_persisted_response_item(item: &ResponseItem) -> bool {
match item {
ResponseItem::Message { .. }
| ResponseItem::Reasoning { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. } => true,
ResponseItem::WebSearchCall { .. } | ResponseItem::Other => false,
}
}
@@ -19,14 +19,17 @@ use tracing::info;
use tracing::warn;
use uuid::Uuid;
use super::SESSIONS_SUBDIR;
use super::list::ConversationsPage;
use super::list::Cursor;
use super::list::get_conversations;
use super::policy::is_persisted_response_item;
use crate::config::Config;
use crate::conversation_manager::InitialHistory;
use crate::git_info::GitInfo;
use crate::git_info::collect_git_info;
use codex_protocol::models::ResponseItem;
const SESSIONS_SUBDIR: &str = "sessions";
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct SessionMeta {
pub id: Uuid,
@@ -65,7 +68,7 @@ pub struct SavedSession {
/// $ fx ~/.codex/sessions/rollout-2025-05-07T17-24-21-5973b6c0-94b8-487b-a530-2aeb6098ae0e.jsonl
/// ```
#[derive(Clone)]
pub(crate) struct RolloutRecorder {
pub struct RolloutRecorder {
tx: Sender<RolloutCmd>,
}
@@ -76,6 +79,16 @@ enum RolloutCmd {
}
impl RolloutRecorder {
#[allow(dead_code)]
/// List conversations (rollout files) under the provided Codex home directory.
pub async fn list_conversations(
codex_home: &Path,
page_size: usize,
cursor: Option<&Cursor>,
) -> std::io::Result<ConversationsPage> {
get_conversations(codex_home, page_size, cursor).await
}
/// Attempt to create a new [`RolloutRecorder`]. If the sessions directory
/// cannot be created or the rollout file cannot be opened we return the
/// error so the caller can decide whether to disable persistence.
@@ -125,21 +138,11 @@ impl RolloutRecorder {
pub(crate) async fn record_items(&self, items: &[ResponseItem]) -> std::io::Result<()> {
let mut filtered = Vec::new();
for item in items {
match item {
// Note that function calls may look a bit strange if they are
// "fully qualified MCP tool calls," so we could consider
// reformatting them in that case.
ResponseItem::Message { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::Reasoning { .. } => filtered.push(item.clone()),
ResponseItem::WebSearchCall { .. } | ResponseItem::Other => {
// These should never be serialized.
continue;
}
// Note that function calls may look a bit strange if they are
// "fully qualified MCP tool calls," so we could consider
// reformatting them in that case.
if is_persisted_response_item(item) {
filtered.push(item.clone());
}
}
if filtered.is_empty() {
@@ -183,16 +186,11 @@ impl RolloutRecorder {
continue;
}
match serde_json::from_value::<ResponseItem>(v.clone()) {
Ok(item) => match item {
ResponseItem::Message { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::Reasoning { .. } => items.push(item),
ResponseItem::WebSearchCall { .. } | ResponseItem::Other => {}
},
Ok(item) => {
if is_persisted_response_item(&item) {
items.push(item);
}
}
Err(e) => {
warn!("failed to parse item: {v:?}, error: {e}");
}
@@ -293,17 +291,8 @@ async fn rollout_writer(
match cmd {
RolloutCmd::AddItems(items) => {
for item in items {
match item {
ResponseItem::Message { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::Reasoning { .. } => {
writer.write_line(&item).await?;
}
ResponseItem::WebSearchCall { .. } | ResponseItem::Other => {}
if is_persisted_response_item(&item) {
writer.write_line(&item).await?;
}
}
}
+384
View File
@@ -0,0 +1,384 @@
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::fs::File;
use std::fs::{self};
use std::io::Write;
use std::path::Path;
use tempfile::TempDir;
use time::OffsetDateTime;
use time::PrimitiveDateTime;
use time::format_description::FormatItem;
use time::macros::format_description;
use uuid::Uuid;
use crate::rollout::list::ConversationItem;
use crate::rollout::list::ConversationsPage;
use crate::rollout::list::Cursor;
use crate::rollout::list::get_conversation;
use crate::rollout::list::get_conversations;
fn write_session_file(
root: &Path,
ts_str: &str,
uuid: Uuid,
num_records: usize,
) -> std::io::Result<(OffsetDateTime, Uuid)> {
let format: &[FormatItem] =
format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]");
let dt = PrimitiveDateTime::parse(ts_str, format)
.unwrap()
.assume_utc();
let dir = root
.join("sessions")
.join(format!("{:04}", dt.year()))
.join(format!("{:02}", u8::from(dt.month())))
.join(format!("{:02}", dt.day()));
fs::create_dir_all(&dir)?;
let filename = format!("rollout-{ts_str}-{uuid}.jsonl");
let file_path = dir.join(filename);
let mut file = File::create(file_path)?;
let meta = serde_json::json!({
"timestamp": ts_str,
"id": uuid.to_string()
});
writeln!(file, "{meta}")?;
for i in 0..num_records {
let rec = serde_json::json!({
"record_type": "response",
"index": i
});
writeln!(file, "{rec}")?;
}
Ok((dt, uuid))
}
#[tokio::test]
async fn test_list_conversations_latest_first() {
let temp = TempDir::new().unwrap();
let home = temp.path();
// Fixed UUIDs for deterministic expectations
let u1 = Uuid::from_u128(1);
let u2 = Uuid::from_u128(2);
let u3 = Uuid::from_u128(3);
// Create three sessions across three days
write_session_file(home, "2025-01-01T12-00-00", u1, 3).unwrap();
write_session_file(home, "2025-01-02T12-00-00", u2, 3).unwrap();
write_session_file(home, "2025-01-03T12-00-00", u3, 3).unwrap();
let page = get_conversations(home, 10, None).await.unwrap();
// Build expected objects
let p1 = home
.join("sessions")
.join("2025")
.join("01")
.join("03")
.join(format!("rollout-2025-01-03T12-00-00-{u3}.jsonl"));
let p2 = home
.join("sessions")
.join("2025")
.join("01")
.join("02")
.join(format!("rollout-2025-01-02T12-00-00-{u2}.jsonl"));
let p3 = home
.join("sessions")
.join("2025")
.join("01")
.join("01")
.join(format!("rollout-2025-01-01T12-00-00-{u1}.jsonl"));
let head_3 = vec![
serde_json::json!({"timestamp": "2025-01-03T12-00-00", "id": u3.to_string()}),
serde_json::json!({"record_type": "response", "index": 0}),
serde_json::json!({"record_type": "response", "index": 1}),
serde_json::json!({"record_type": "response", "index": 2}),
];
let head_2 = vec![
serde_json::json!({"timestamp": "2025-01-02T12-00-00", "id": u2.to_string()}),
serde_json::json!({"record_type": "response", "index": 0}),
serde_json::json!({"record_type": "response", "index": 1}),
serde_json::json!({"record_type": "response", "index": 2}),
];
let head_1 = vec![
serde_json::json!({"timestamp": "2025-01-01T12-00-00", "id": u1.to_string()}),
serde_json::json!({"record_type": "response", "index": 0}),
serde_json::json!({"record_type": "response", "index": 1}),
serde_json::json!({"record_type": "response", "index": 2}),
];
let expected_cursor: Cursor =
serde_json::from_str(&format!("\"2025-01-01T12-00-00|{u1}\"")).unwrap();
let expected = ConversationsPage {
items: vec![
ConversationItem {
path: p1,
head: head_3,
},
ConversationItem {
path: p2,
head: head_2,
},
ConversationItem {
path: p3,
head: head_1,
},
],
next_cursor: Some(expected_cursor),
num_scanned_files: 3,
reached_scan_cap: false,
};
assert_eq!(page, expected);
}
#[tokio::test]
async fn test_pagination_cursor() {
let temp = TempDir::new().unwrap();
let home = temp.path();
// Fixed UUIDs for deterministic expectations
let u1 = Uuid::from_u128(11);
let u2 = Uuid::from_u128(22);
let u3 = Uuid::from_u128(33);
let u4 = Uuid::from_u128(44);
let u5 = Uuid::from_u128(55);
// Oldest to newest
write_session_file(home, "2025-03-01T09-00-00", u1, 1).unwrap();
write_session_file(home, "2025-03-02T09-00-00", u2, 1).unwrap();
write_session_file(home, "2025-03-03T09-00-00", u3, 1).unwrap();
write_session_file(home, "2025-03-04T09-00-00", u4, 1).unwrap();
write_session_file(home, "2025-03-05T09-00-00", u5, 1).unwrap();
let page1 = get_conversations(home, 2, None).await.unwrap();
let p5 = home
.join("sessions")
.join("2025")
.join("03")
.join("05")
.join(format!("rollout-2025-03-05T09-00-00-{u5}.jsonl"));
let p4 = home
.join("sessions")
.join("2025")
.join("03")
.join("04")
.join(format!("rollout-2025-03-04T09-00-00-{u4}.jsonl"));
let head_5 = vec![
serde_json::json!({"timestamp": "2025-03-05T09-00-00", "id": u5.to_string()}),
serde_json::json!({"record_type": "response", "index": 0}),
];
let head_4 = vec![
serde_json::json!({"timestamp": "2025-03-04T09-00-00", "id": u4.to_string()}),
serde_json::json!({"record_type": "response", "index": 0}),
];
let expected_cursor1: Cursor =
serde_json::from_str(&format!("\"2025-03-04T09-00-00|{u4}\"")).unwrap();
let expected_page1 = ConversationsPage {
items: vec![
ConversationItem {
path: p5,
head: head_5,
},
ConversationItem {
path: p4,
head: head_4,
},
],
next_cursor: Some(expected_cursor1.clone()),
num_scanned_files: 3, // scanned 05, 04, and peeked at 03 before breaking
reached_scan_cap: false,
};
assert_eq!(page1, expected_page1);
let page2 = get_conversations(home, 2, page1.next_cursor.as_ref())
.await
.unwrap();
let p3 = home
.join("sessions")
.join("2025")
.join("03")
.join("03")
.join(format!("rollout-2025-03-03T09-00-00-{u3}.jsonl"));
let p2 = home
.join("sessions")
.join("2025")
.join("03")
.join("02")
.join(format!("rollout-2025-03-02T09-00-00-{u2}.jsonl"));
let head_3 = vec![
serde_json::json!({"timestamp": "2025-03-03T09-00-00", "id": u3.to_string()}),
serde_json::json!({"record_type": "response", "index": 0}),
];
let head_2 = vec![
serde_json::json!({"timestamp": "2025-03-02T09-00-00", "id": u2.to_string()}),
serde_json::json!({"record_type": "response", "index": 0}),
];
let expected_cursor2: Cursor =
serde_json::from_str(&format!("\"2025-03-02T09-00-00|{u2}\"")).unwrap();
let expected_page2 = ConversationsPage {
items: vec![
ConversationItem {
path: p3,
head: head_3,
},
ConversationItem {
path: p2,
head: head_2,
},
],
next_cursor: Some(expected_cursor2.clone()),
num_scanned_files: 5, // scanned 05, 04 (anchor), 03, 02, and peeked at 01
reached_scan_cap: false,
};
assert_eq!(page2, expected_page2);
let page3 = get_conversations(home, 2, page2.next_cursor.as_ref())
.await
.unwrap();
let p1 = home
.join("sessions")
.join("2025")
.join("03")
.join("01")
.join(format!("rollout-2025-03-01T09-00-00-{u1}.jsonl"));
let head_1 = vec![
serde_json::json!({"timestamp": "2025-03-01T09-00-00", "id": u1.to_string()}),
serde_json::json!({"record_type": "response", "index": 0}),
];
let expected_cursor3: Cursor =
serde_json::from_str(&format!("\"2025-03-01T09-00-00|{u1}\"")).unwrap();
let expected_page3 = ConversationsPage {
items: vec![ConversationItem {
path: p1,
head: head_1,
}],
next_cursor: Some(expected_cursor3.clone()),
num_scanned_files: 5, // scanned 05, 04 (anchor), 03, 02 (anchor), 01
reached_scan_cap: false,
};
assert_eq!(page3, expected_page3);
}
#[tokio::test]
async fn test_get_conversation_contents() {
let temp = TempDir::new().unwrap();
let home = temp.path();
let uuid = Uuid::new_v4();
let ts = "2025-04-01T10-30-00";
write_session_file(home, ts, uuid, 2).unwrap();
let page = get_conversations(home, 1, None).await.unwrap();
let path = &page.items[0].path;
let content = get_conversation(path).await.unwrap();
// Page equality (single item)
let expected_path = home
.join("sessions")
.join("2025")
.join("04")
.join("01")
.join(format!("rollout-2025-04-01T10-30-00-{uuid}.jsonl"));
let expected_head = vec![
serde_json::json!({"timestamp": ts, "id": uuid.to_string()}),
serde_json::json!({"record_type": "response", "index": 0}),
serde_json::json!({"record_type": "response", "index": 1}),
];
let expected_cursor: Cursor = serde_json::from_str(&format!("\"{ts}|{uuid}\"")).unwrap();
let expected_page = ConversationsPage {
items: vec![ConversationItem {
path: expected_path.clone(),
head: expected_head,
}],
next_cursor: Some(expected_cursor),
num_scanned_files: 1,
reached_scan_cap: false,
};
assert_eq!(page, expected_page);
// Entire file contents equality
let meta = serde_json::json!({"timestamp": ts, "id": uuid.to_string()});
let rec0 = serde_json::json!({"record_type": "response", "index": 0});
let rec1 = serde_json::json!({"record_type": "response", "index": 1});
let expected_content = format!("{meta}\n{rec0}\n{rec1}\n");
assert_eq!(content, expected_content);
}
#[tokio::test]
async fn test_stable_ordering_same_second_pagination() {
let temp = TempDir::new().unwrap();
let home = temp.path();
let ts = "2025-07-01T00-00-00";
let u1 = Uuid::from_u128(1);
let u2 = Uuid::from_u128(2);
let u3 = Uuid::from_u128(3);
write_session_file(home, ts, u1, 0).unwrap();
write_session_file(home, ts, u2, 0).unwrap();
write_session_file(home, ts, u3, 0).unwrap();
let page1 = get_conversations(home, 2, None).await.unwrap();
let p3 = home
.join("sessions")
.join("2025")
.join("07")
.join("01")
.join(format!("rollout-2025-07-01T00-00-00-{u3}.jsonl"));
let p2 = home
.join("sessions")
.join("2025")
.join("07")
.join("01")
.join(format!("rollout-2025-07-01T00-00-00-{u2}.jsonl"));
let head = |u: Uuid| -> Vec<serde_json::Value> {
vec![serde_json::json!({"timestamp": ts, "id": u.to_string()})]
};
let expected_cursor1: Cursor = serde_json::from_str(&format!("\"{ts}|{u2}\"")).unwrap();
let expected_page1 = ConversationsPage {
items: vec![
ConversationItem {
path: p3,
head: head(u3),
},
ConversationItem {
path: p2,
head: head(u2),
},
],
next_cursor: Some(expected_cursor1.clone()),
num_scanned_files: 3, // scanned u3, u2, peeked u1
reached_scan_cap: false,
};
assert_eq!(page1, expected_page1);
let page2 = get_conversations(home, 2, page1.next_cursor.as_ref())
.await
.unwrap();
let p1 = home
.join("sessions")
.join("2025")
.join("07")
.join("01")
.join(format!("rollout-2025-07-01T00-00-00-{u1}.jsonl"));
let expected_cursor2: Cursor = serde_json::from_str(&format!("\"{ts}|{u1}\"")).unwrap();
let expected_page2 = ConversationsPage {
items: vec![ConversationItem {
path: p1,
head: head(u1),
}],
next_cursor: Some(expected_cursor2.clone()),
num_scanned_files: 3, // scanned u3, u2 (anchor), u1
reached_scan_cap: false,
};
assert_eq!(page2, expected_page2);
}
+7
View File
@@ -53,6 +53,13 @@ pub fn assess_patch_safety(
// paths outside the project.
match get_platform_sandbox() {
Some(sandbox_type) => SafetyCheck::AutoApprove { sandbox_type },
None if sandbox_policy == &SandboxPolicy::DangerFullAccess => {
// If the user has explicitly requested DangerFullAccess, then
// we can auto-approve even without a sandbox.
SafetyCheck::AutoApprove {
sandbox_type: SandboxType::None,
}
}
None => SafetyCheck::AskUser,
}
} else if policy == AskForApproval::Never {
-37
View File
@@ -1,37 +0,0 @@
const DEFAULT_ORIGINATOR: &str = "codex_cli_rs";
pub fn get_codex_user_agent(originator: Option<&str>) -> String {
let build_version = env!("CARGO_PKG_VERSION");
let os_info = os_info::get();
format!(
"{}/{build_version} ({} {}; {}) {}",
originator.unwrap_or(DEFAULT_ORIGINATOR),
os_info.os_type(),
os_info.version(),
os_info.architecture().unwrap_or("unknown"),
crate::terminal::user_agent()
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_codex_user_agent() {
let user_agent = get_codex_user_agent(None);
assert!(user_agent.starts_with("codex_cli_rs/"));
}
#[test]
#[cfg(target_os = "macos")]
fn test_macos() {
use regex_lite::Regex;
let user_agent = get_codex_user_agent(None);
let re = Regex::new(
r"^codex_cli_rs/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\) (\S+)$",
)
.unwrap();
assert!(re.is_match(&user_agent));
}
}
+19 -13
View File
@@ -96,7 +96,7 @@ fn write_auth_json(
"OPENAI_API_KEY": openai_api_key,
"tokens": tokens,
// RFC3339 datetime; value doesn't matter for these tests
"last_refresh": "2025-08-06T20:41:36.232376Z",
"last_refresh": chrono::Utc::now(),
});
std::fs::write(
@@ -414,12 +414,15 @@ async fn prefers_chatgpt_token_when_config_prefers_chatgpt() {
config.model_provider = model_provider;
config.preferred_auth_method = AuthMode::ChatGPT;
let auth_manager =
match CodexAuth::from_codex_home(codex_home.path(), config.preferred_auth_method) {
Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth),
Ok(None) => panic!("No CodexAuth found in codex_home"),
Err(e) => panic!("Failed to load CodexAuth: {e}"),
};
let auth_manager = match CodexAuth::from_codex_home(
codex_home.path(),
config.preferred_auth_method,
&config.responses_originator_header,
) {
Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth),
Ok(None) => panic!("No CodexAuth found in codex_home"),
Err(e) => panic!("Failed to load CodexAuth: {e}"),
};
let conversation_manager = ConversationManager::new(auth_manager);
let NewConversation {
conversation: codex,
@@ -495,12 +498,15 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
config.model_provider = model_provider;
config.preferred_auth_method = AuthMode::ApiKey;
let auth_manager =
match CodexAuth::from_codex_home(codex_home.path(), config.preferred_auth_method) {
Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth),
Ok(None) => panic!("No CodexAuth found in codex_home"),
Err(e) => panic!("Failed to load CodexAuth: {e}"),
};
let auth_manager = match CodexAuth::from_codex_home(
codex_home.path(),
config.preferred_auth_method,
&config.responses_originator_header,
) {
Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth),
Ok(None) => panic!("No CodexAuth found in codex_home"),
Err(e) => panic!("Failed to load CodexAuth: {e}"),
};
let conversation_manager = ConversationManager::new(auth_manager);
let NewConversation {
conversation: codex,
+1
View File
@@ -191,6 +191,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
let conversation_manager = ConversationManager::new(AuthManager::shared(
config.codex_home.clone(),
config.preferred_auth_method,
config.responses_originator_header.clone(),
));
let NewConversation {
conversation_id: _,
+7 -1
View File
@@ -151,7 +151,13 @@ pub fn run(
// Use the same tree-walker library that ripgrep uses. We use it directly so
// that we can leverage the parallelism it provides.
let mut walk_builder = WalkBuilder::new(search_directory);
walk_builder.threads(num_walk_builder_threads);
walk_builder
.threads(num_walk_builder_threads)
// Allow hidden entries.
.hidden(false)
// Don't require git to be present to apply to apply git-related ignore rules.
.require_git(false);
if !exclude.is_empty() {
let mut override_builder = OverrideBuilder::new(search_directory);
for exclude in exclude {
+4
View File
@@ -30,3 +30,7 @@ fix *args:
install:
rustup show active-toolchain
cargo fetch
# Run the MCP server
mcp-server-run *args:
cargo run -p codex-mcp-server -- "$@"
+1 -1
View File
@@ -17,7 +17,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
tempfile = "3"
thiserror = "2.0.12"
thiserror = "2.0.16"
tiny_http = "0.12"
tokio = { version = "1", features = [
"io-std",
+13 -2
View File
@@ -30,10 +30,11 @@ pub struct ServerOptions {
pub port: u16,
pub open_browser: bool,
pub force_state: Option<String>,
pub originator: String,
}
impl ServerOptions {
pub fn new(codex_home: PathBuf, client_id: String) -> Self {
pub fn new(codex_home: PathBuf, client_id: String, originator: String) -> Self {
Self {
codex_home,
client_id: client_id.to_string(),
@@ -41,6 +42,7 @@ impl ServerOptions {
port: DEFAULT_PORT,
open_browser: true,
force_state: None,
originator,
}
}
}
@@ -96,7 +98,14 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
let server = Arc::new(server);
let redirect_uri = format!("http://localhost:{actual_port}/auth/callback");
let auth_url = build_authorize_url(&opts.issuer, &opts.client_id, &redirect_uri, &pkce, &state);
let auth_url = build_authorize_url(
&opts.issuer,
&opts.client_id,
&redirect_uri,
&pkce,
&state,
&opts.originator,
);
if opts.open_browser {
let _ = webbrowser::open(&auth_url);
@@ -279,6 +288,7 @@ fn build_authorize_url(
redirect_uri: &str,
pkce: &PkceCodes,
state: &str,
originator: &str,
) -> String {
let query = vec![
("response_type", "code"),
@@ -290,6 +300,7 @@ fn build_authorize_url(
("id_token_add_organizations", "true"),
("codex_cli_simplified_flow", "true"),
("state", state),
("originator", originator),
];
let qs = query
.into_iter()
@@ -100,6 +100,7 @@ async fn end_to_end_login_flow_persists_auth_json() {
port: 0,
open_browser: false,
force_state: Some(state),
originator: "test_originator".to_string(),
};
let server = run_login_server(opts).unwrap();
let login_port = server.actual_port;
@@ -158,6 +159,7 @@ async fn creates_missing_codex_home_dir() {
port: 0,
open_browser: false,
force_state: Some(state),
originator: "test_originator".to_string(),
};
let server = run_login_server(opts).unwrap();
let login_port = server.actual_port;
@@ -3,37 +3,31 @@ use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use codex_core::AuthManager;
use codex_core::CodexConversation;
use codex_core::ConversationManager;
use codex_core::NewConversation;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
use codex_core::config::load_config_as_toml;
use codex_core::git_info::git_diff_to_remote;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::ReviewDecision;
use codex_protocol::mcp_protocol::AuthMode;
use codex_protocol::mcp_protocol::GitDiffToRemoteResponse;
use mcp_types::JSONRPCErrorError;
use mcp_types::RequestId;
use tokio::sync::Mutex;
use tokio::sync::oneshot;
use tracing::error;
use uuid::Uuid;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::json_to_toml::json_to_toml;
use crate::outgoing_message::OutgoingMessageSender;
use crate::outgoing_message::OutgoingNotification;
use codex_core::AuthManager;
use codex_core::CodexConversation;
use codex_core::ConversationManager;
use codex_core::NewConversation;
use codex_core::auth::CLIENT_ID;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
use codex_core::config::load_config_as_toml;
use codex_core::exec::ExecParams;
use codex_core::exec_env::create_env;
use codex_core::get_platform_sandbox;
use codex_core::git_info::git_diff_to_remote;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::InputItem as CoreInputItem;
use codex_core::protocol::Op;
use codex_core::protocol::ReviewDecision;
use codex_login::ServerOptions as LoginServerOptions;
use codex_login::ShutdownHandle;
use codex_login::run_login_server;
@@ -42,13 +36,17 @@ use codex_protocol::mcp_protocol::AddConversationListenerParams;
use codex_protocol::mcp_protocol::AddConversationSubscriptionResponse;
use codex_protocol::mcp_protocol::ApplyPatchApprovalParams;
use codex_protocol::mcp_protocol::ApplyPatchApprovalResponse;
use codex_protocol::mcp_protocol::AuthMode;
use codex_protocol::mcp_protocol::AuthStatusChangeNotification;
use codex_protocol::mcp_protocol::ClientRequest;
use codex_protocol::mcp_protocol::ConversationId;
use codex_protocol::mcp_protocol::EXEC_COMMAND_APPROVAL_METHOD;
use codex_protocol::mcp_protocol::ExecArbitraryCommandResponse;
use codex_protocol::mcp_protocol::ExecCommandApprovalParams;
use codex_protocol::mcp_protocol::ExecCommandApprovalResponse;
use codex_protocol::mcp_protocol::ExecOneOffCommandParams;
use codex_protocol::mcp_protocol::GetConfigTomlResponse;
use codex_protocol::mcp_protocol::GitDiffToRemoteResponse;
use codex_protocol::mcp_protocol::InputItem as WireInputItem;
use codex_protocol::mcp_protocol::InterruptConversationParams;
use codex_protocol::mcp_protocol::InterruptConversationResponse;
@@ -63,6 +61,12 @@ use codex_protocol::mcp_protocol::SendUserMessageResponse;
use codex_protocol::mcp_protocol::SendUserTurnParams;
use codex_protocol::mcp_protocol::SendUserTurnResponse;
use codex_protocol::mcp_protocol::ServerNotification;
use mcp_types::JSONRPCErrorError;
use mcp_types::RequestId;
use tokio::sync::Mutex;
use tokio::sync::oneshot;
use tracing::error;
use uuid::Uuid;
// Duration before a ChatGPT login attempt is abandoned.
const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
@@ -152,6 +156,9 @@ impl CodexMessageProcessor {
ClientRequest::GetConfigToml { request_id } => {
self.get_config_toml(request_id).await;
}
ClientRequest::ExecOneOffCommand { request_id, params } => {
self.exec_one_off_command(request_id, params).await;
}
}
}
@@ -160,7 +167,11 @@ impl CodexMessageProcessor {
let opts = LoginServerOptions {
open_browser: false,
..LoginServerOptions::new(config.codex_home.clone(), CLIENT_ID.to_string())
..LoginServerOptions::new(
config.codex_home.clone(),
CLIENT_ID.to_string(),
config.responses_originator_header.clone(),
)
};
enum LoginChatGptReply {
@@ -416,6 +427,76 @@ impl CodexMessageProcessor {
self.outgoing.send_response(request_id, response).await;
}
async fn exec_one_off_command(&self, request_id: RequestId, params: ExecOneOffCommandParams) {
tracing::debug!("ExecOneOffCommand params: {params:?}");
if params.command.is_empty() {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "command must not be empty".to_string(),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
let cwd = params.cwd.unwrap_or_else(|| self.config.cwd.clone());
let env = create_env(&self.config.shell_environment_policy);
let timeout_ms = params.timeout_ms;
let exec_params = ExecParams {
command: params.command,
cwd,
timeout_ms,
env,
with_escalated_permissions: None,
justification: None,
};
let effective_policy = params
.sandbox_policy
.unwrap_or_else(|| self.config.sandbox_policy.clone());
let sandbox_type = match &effective_policy {
codex_core::protocol::SandboxPolicy::DangerFullAccess => {
codex_core::exec::SandboxType::None
}
_ => get_platform_sandbox().unwrap_or(codex_core::exec::SandboxType::None),
};
tracing::debug!("Sandbox type: {sandbox_type:?}");
let codex_linux_sandbox_exe = self.config.codex_linux_sandbox_exe.clone();
let outgoing = self.outgoing.clone();
let req_id = request_id;
tokio::spawn(async move {
match codex_core::exec::process_exec_tool_call(
exec_params,
sandbox_type,
&effective_policy,
&codex_linux_sandbox_exe,
None,
)
.await
{
Ok(output) => {
let response = ExecArbitraryCommandResponse {
exit_code: output.exit_code,
stdout: output.stdout.text,
stderr: output.stderr.text,
};
outgoing.send_response(req_id, response).await;
}
Err(err) => {
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("exec failed: {err}"),
data: None,
};
outgoing.send_error(req_id, error).await;
}
}
});
}
async fn process_new_conversation(&self, request_id: RequestId, params: NewConversationParams) {
let config = match derive_config_from_params(params, self.codex_linux_sandbox_exe.clone()) {
Ok(config) => config,
+5 -2
View File
@@ -53,8 +53,11 @@ impl MessageProcessor {
config: Arc<Config>,
) -> Self {
let outgoing = Arc::new(outgoing);
let auth_manager =
AuthManager::shared(config.codex_home.clone(), config.preferred_auth_method);
let auth_manager = AuthManager::shared(
config.codex_home.clone(),
config.preferred_auth_method,
config.responses_originator_header.clone(),
);
let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone()));
let codex_message_processor = CodexMessageProcessor::new(
auth_manager,
+12
View File
@@ -35,6 +35,18 @@ pub enum ReasoningSummary {
None,
}
/// Controls output length/detail on GPT-5 models via the Responses API.
/// Serialized with lowercase values to match the OpenAI API.
#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum Verbosity {
Low,
#[default]
Medium,
High,
}
#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Default, Serialize, Display, TS)]
#[serde(rename_all = "kebab-case")]
#[strum(serialize_all = "kebab-case")]
+30
View File
@@ -106,6 +106,12 @@ pub enum ClientRequest {
#[serde(rename = "id")]
request_id: RequestId,
},
/// Execute a command (argv vector) under the server's sandbox.
ExecOneOffCommand {
#[serde(rename = "id")]
request_id: RequestId,
params: ExecOneOffCommandParams,
},
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)]
@@ -218,6 +224,30 @@ pub struct GetAuthStatusParams {
pub refresh_token: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
#[serde(rename_all = "camelCase")]
pub struct ExecOneOffCommandParams {
/// Command argv to execute.
pub command: Vec<String>,
/// Timeout of the command in milliseconds.
/// If not specified, a sensible default is used server-side.
pub timeout_ms: Option<u64>,
/// Optional working directory for the process. Defaults to server config cwd.
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<PathBuf>,
/// Optional explicit sandbox policy overriding the server default.
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox_policy: Option<SandboxPolicy>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
#[serde(rename_all = "camelCase")]
pub struct ExecArbitraryCommandResponse {
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
#[serde(rename_all = "camelCase")]
pub struct GetAuthStatusResponse {
+5 -1
View File
@@ -22,7 +22,6 @@ workspace = true
[dependencies]
anyhow = "1"
arboard = "3"
async-stream = "0.3.6"
base64 = "0.22.1"
chrono = { version = "0.4", features = ["serde"] }
@@ -93,6 +92,11 @@ pathdiff = "0.2"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
# Clipboard support via `arboard` is not available on Android/Termux.
# Only include it for non-Android targets so the crate builds on Android.
[target.'cfg(not(target_os = "android"))'.dependencies]
arboard = "3"
[dev-dependencies]
chrono = { version = "0.4", features = ["serde"] }
+19 -4
View File
@@ -47,6 +47,7 @@ pub struct PastedImageInfo {
}
/// Capture image from system clipboard, encode to PNG, and return bytes + info.
#[cfg(not(target_os = "android"))]
pub fn paste_image_as_png() -> Result<(Vec<u8>, PastedImageInfo), PasteImageError> {
tracing::debug!("attempting clipboard image read");
let mut cb = arboard::Clipboard::new()
@@ -70,10 +71,7 @@ pub fn paste_image_as_png() -> Result<(Vec<u8>, PastedImageInfo), PasteImageErro
.map_err(|e| PasteImageError::EncodeFailed(e.to_string()))?;
}
tracing::debug!(
"clipboard image encoded to PNG ({len} bytes)",
len = png.len()
);
tracing::debug!("clipboard image encoded to PNG ({}) bytes", png.len());
Ok((
png,
PastedImageInfo {
@@ -84,7 +82,16 @@ pub fn paste_image_as_png() -> Result<(Vec<u8>, PastedImageInfo), PasteImageErro
))
}
/// Android/Termux does not support arboard; return a clear error.
#[cfg(target_os = "android")]
pub fn paste_image_as_png() -> Result<(Vec<u8>, PastedImageInfo), PasteImageError> {
Err(PasteImageError::ClipboardUnavailable(
"clipboard image paste is unsupported on Android".into(),
))
}
/// Convenience: write to a temp file and return its path + info.
#[cfg(not(target_os = "android"))]
pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImageError> {
let (png, info) = paste_image_as_png()?;
// Create a unique temporary file with a .png suffix to avoid collisions.
@@ -101,6 +108,14 @@ pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImag
Ok((path, info))
}
#[cfg(target_os = "android")]
pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImageError> {
// Keep error consistent with paste_image_as_png.
Err(PasteImageError::ClipboardUnavailable(
"clipboard image paste is unsupported on Android".into(),
))
}
/// Normalize pasted text that may represent a filesystem path.
///
/// Supports:
+1 -9
View File
@@ -1032,7 +1032,7 @@ pub(crate) fn empty_mcp_output() -> PlainHistoryCell {
" • No MCP servers configured.".italic().into(),
Line::from(vec![
" See the ".into(),
"\u{1b}]8;;https://github.com/openai/codex/blob/main/codex-rs/config.md#mcp_servers\u{7}MCP docs\u{1b}]8;;\u{7}".underlined(),
"\u{1b}]8;;https://github.com/openai/codex/blob/main/docs/config.md#mcp_servers\u{7}MCP docs\u{1b}]8;;\u{7}".underlined(),
" to configure them.".into(),
])
.style(Style::default().add_modifier(Modifier::DIM)),
@@ -1076,14 +1076,6 @@ pub(crate) fn new_mcp_tools_output(
lines.push(vec![" • Command: ".into(), cmd_display.into()].into());
}
if let Some(env) = cfg.env.as_ref()
&& !env.is_empty()
{
let mut env_pairs: Vec<String> = env.iter().map(|(k, v)| format!("{k}={v}")).collect();
env_pairs.sort();
lines.push(vec![" • Env: ".into(), env_pairs.join(" ").into()].into());
}
if names.is_empty() {
lines.push(" • Tools: (none)".into());
} else {
+11 -5
View File
@@ -301,20 +301,22 @@ async fn run_ratatui_app(
let Cli { prompt, images, .. } = cli;
let auth_manager = AuthManager::shared(config.codex_home.clone(), config.preferred_auth_method);
let auth_manager = AuthManager::shared(
config.codex_home.clone(),
config.preferred_auth_method,
config.responses_originator_header.clone(),
);
let login_status = get_login_status(&config);
let should_show_onboarding =
should_show_onboarding(login_status, &config, should_show_trust_screen);
if should_show_onboarding {
let directory_trust_decision = run_onboarding_app(
OnboardingScreenArgs {
codex_home: config.codex_home.clone(),
cwd: config.cwd.clone(),
show_login_screen: should_show_login_screen(login_status, &config),
show_trust_screen: should_show_trust_screen,
login_status,
preferred_auth_method: config.preferred_auth_method,
auth_manager: auth_manager.clone(),
config: config.clone(),
},
&mut tui,
)
@@ -357,7 +359,11 @@ fn get_login_status(config: &Config) -> LoginStatus {
// Reading the OpenAI API key is an async operation because it may need
// to refresh the token. Block on it.
let codex_home = config.codex_home.clone();
match CodexAuth::from_codex_home(&codex_home, config.preferred_auth_method) {
match CodexAuth::from_codex_home(
&codex_home,
config.preferred_auth_method,
&config.responses_originator_header,
) {
Ok(Some(auth)) => LoginStatus::AuthMode(auth.mode),
Ok(None) => LoginStatus::NotAuthenticated,
Err(err) => {
+7 -1
View File
@@ -2,6 +2,7 @@
use codex_core::AuthManager;
use codex_core::auth::CLIENT_ID;
use codex_core::config::Config;
use codex_login::ServerOptions;
use codex_login::ShutdownHandle;
use codex_login::run_login_server;
@@ -113,6 +114,7 @@ pub(crate) struct AuthModeWidget {
pub login_status: LoginStatus,
pub preferred_auth_method: AuthMode,
pub auth_manager: Arc<AuthManager>,
pub config: Config,
}
impl AuthModeWidget {
@@ -314,7 +316,11 @@ impl AuthModeWidget {
}
self.error = None;
let opts = ServerOptions::new(self.codex_home.clone(), CLIENT_ID.to_string());
let opts = ServerOptions::new(
self.codex_home.clone(),
CLIENT_ID.to_string(),
self.config.responses_originator_header.clone(),
);
match run_login_server(opts) {
Ok(child) => {
let sign_in_state = self.sign_in_state.clone();
@@ -1,4 +1,5 @@
use codex_core::AuthManager;
use codex_core::config::Config;
use codex_core::git_info::get_git_repo_root;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -21,7 +22,6 @@ use crate::tui::FrameRequester;
use crate::tui::Tui;
use crate::tui::TuiEvent;
use color_eyre::eyre::Result;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::RwLock;
@@ -53,26 +53,25 @@ pub(crate) struct OnboardingScreen {
}
pub(crate) struct OnboardingScreenArgs {
pub codex_home: PathBuf,
pub cwd: PathBuf,
pub show_trust_screen: bool,
pub show_login_screen: bool,
pub login_status: LoginStatus,
pub preferred_auth_method: AuthMode,
pub auth_manager: Arc<AuthManager>,
pub config: Config,
}
impl OnboardingScreen {
pub(crate) fn new(tui: &mut Tui, args: OnboardingScreenArgs) -> Self {
let OnboardingScreenArgs {
codex_home,
cwd,
show_trust_screen,
show_login_screen,
login_status,
preferred_auth_method,
auth_manager,
config,
} = args;
let preferred_auth_method = config.preferred_auth_method;
let cwd = config.cwd.clone();
let codex_home = config.codex_home.clone();
let mut steps: Vec<Step> = vec![Step::Welcome(WelcomeWidget {
is_logged_in: !matches!(login_status, LoginStatus::NotAuthenticated),
})];
@@ -84,8 +83,9 @@ impl OnboardingScreen {
sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)),
codex_home: codex_home.clone(),
login_status,
preferred_auth_method,
auth_manager,
preferred_auth_method,
config,
}))
}
let is_git_repo = get_git_repo_root(&cwd).is_some();
+5 -5
View File
@@ -9,7 +9,7 @@ use std::path::Path;
use std::path::PathBuf;
use codex_core::config::Config;
use codex_core::user_agent::get_codex_user_agent;
use codex_core::default_client::create_client;
pub fn get_upgrade_version(config: &Config) -> Option<String> {
let version_file = version_filepath(config);
@@ -22,8 +22,9 @@ pub fn get_upgrade_version(config: &Config) -> Option<String> {
// Refresh the cached latest version in the background so TUI startup
// isn’t blocked by a network call. The UI reads the previously cached
// value (if any) for this run; the next run shows the banner if needed.
let originator = config.responses_originator_header.clone();
tokio::spawn(async move {
check_for_update(&version_file)
check_for_update(&version_file, &originator)
.await
.inspect_err(|e| tracing::error!("Failed to update version: {e}"))
});
@@ -63,12 +64,11 @@ fn read_version_info(version_file: &Path) -> anyhow::Result<VersionInfo> {
Ok(serde_json::from_str(&contents)?)
}
async fn check_for_update(version_file: &Path) -> anyhow::Result<()> {
async fn check_for_update(version_file: &Path, originator: &str) -> anyhow::Result<()> {
let ReleaseInfo {
tag_name: latest_tag_name,
} = reqwest::Client::new()
} = create_client(originator)
.get(LATEST_RELEASE_URL)
.header("User-Agent", get_codex_user_agent(None))
.send()
.await?
.error_for_status()?
+3 -1
View File
@@ -8,6 +8,8 @@ If you prefer to pay-as-you-go, you can still authenticate with your OpenAI API
export OPENAI_API_KEY="your-api-key-here"
```
This key must, at minimum, have write access to the Responses API.
## Migrating to ChatGPT login from API key
If you've used the Codex CLI before with usage-based billing via an API key and want to switch to using your ChatGPT plan, follow these steps:
@@ -85,4 +87,4 @@ If you run Codex on a remote machine (VPS/server) without a local browser, the l
ssh -L 1455:localhost:1455 <user>@<remote-host>
```
Then, in that SSH session, run `codex` and select "Sign in with ChatGPT". When prompted, open the printed URL (it will be `http://localhost:1455/...`) in your local browser. The traffic will be tunneled to the remote server.
Then, in that SSH session, run `codex` and select "Sign in with ChatGPT". When prompted, open the printed URL (it will be `http://localhost:1455/...`) in your local browser. The traffic will be tunneled to the remote server.
+2 -1
View File
@@ -90,7 +90,7 @@ http_headers = { "X-Example-Header" = "example-value" }
# This will add the HTTP header `X-Example-Features` with the value of the
# `EXAMPLE_FEATURES` environment variable to each request to the model provider
# _if_ the environment variable is set and its value is non-empty.
env_http_headers = { "X-Example-Features": "EXAMPLE_FEATURES" }
env_http_headers = { "X-Example-Features" = "EXAMPLE_FEATURES" }
```
### Per-provider network tuning
@@ -611,6 +611,7 @@ Options that are specific to the TUI.
| `experimental_resume` | string (path) | Resume JSONL path (internal/experimental). |
| `experimental_instructions_file` | string (path) | Replace built‑in instructions (experimental). |
| `experimental_use_exec_command_tool` | boolean | Use experimental exec command tool. |
| `use_experimental_reasoning_summary` | boolean | Use experimental summary for reasoning chain. |
| `responses_originator_header_internal_override` | string | Override `originator` header value. |
| `projects.<path>.trust_level` | string | Mark project/worktree as trusted (only `"trusted"` is recognized). |
| `preferred_auth_method` | `chatgpt` \| `apikey` | Select default auth method (default: `chatgpt`). |