diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index e0242af21da8..19885ae97504 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2134,6 +2134,7 @@ dependencies = [ "codex-utils-cli", "codex-utils-json-to-toml", "codex-utils-path-uri", + "codex-utils-process", "codex-utils-pty", "codex-web-search-extension", "codex-websocket-auth", @@ -2550,6 +2551,7 @@ dependencies = [ "codex-utils-cli", "codex-utils-path", "codex-utils-path-uri", + "codex-utils-process", "codex-utils-pty", "codex-utils-redacted-string", "codex-websocket-auth", @@ -2965,6 +2967,7 @@ dependencies = [ "codex-utils-path", "codex-utils-path-uri", "codex-utils-plugins", + "codex-utils-process", "codex-utils-pty", "codex-utils-redacted-string", "codex-utils-stream-parser", @@ -3084,6 +3087,7 @@ dependencies = [ "codex-utils-path", "codex-utils-path-uri", "codex-utils-plugins", + "codex-utils-process", "dirs", "flate2", "futures", @@ -3201,6 +3205,7 @@ dependencies = [ "codex-utils-cargo-bin", "codex-utils-home-dir", "codex-utils-path-uri", + "codex-utils-process", "codex-utils-pty", "codex-utils-redacted-string", "codex-utils-rustls-provider", @@ -3459,6 +3464,7 @@ dependencies = [ "codex-protocol", "codex-utils-absolute-path", "codex-utils-path-uri", + "codex-utils-process", "codex-utils-pty", "futures", "gix", @@ -3634,6 +3640,7 @@ dependencies = [ "codex-utils-absolute-path", "codex-utils-output-truncation", "codex-utils-path-uri", + "codex-utils-process", "codex-utils-pty", "futures", "libc", @@ -3794,6 +3801,7 @@ dependencies = [ "codex-protocol", "codex-secrets", "codex-terminal-detection", + "codex-utils-process", "codex-utils-template", "codex-workload-identity", "core_test_support", @@ -4005,6 +4013,7 @@ dependencies = [ "codex-protocol", "codex-response-debug-context", "codex-secrets", + "codex-utils-process", "codex-utils-redacted-string", "http 1.4.0", "pretty_assertions", @@ -4409,6 +4418,7 @@ dependencies = [ "codex-state", "codex-utils-absolute-path", "codex-utils-path", + "codex-utils-process", "pretty_assertions", "regex", "serde", @@ -5064,6 +5074,14 @@ dependencies = [ "tokio", ] +[[package]] +name = "codex-utils-process" +version = "0.0.0" +dependencies = [ + "pretty_assertions", + "tokio", +] + [[package]] name = "codex-utils-pty" version = "0.0.0" @@ -5254,6 +5272,7 @@ dependencies = [ "codex-otel", "codex-protocol", "codex-utils-absolute-path", + "codex-utils-process", "codex-utils-pty", "codex-utils-string", "dirs-next", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index adceeb5ed902..a8246dbcb1e8 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -125,6 +125,7 @@ members = [ "utils/image", "utils/json-to-toml", "utils/home-dir", + "utils/process", "utils/pty", "utils/readiness", "utils/redacted-string", @@ -300,6 +301,7 @@ codex-utils-output-truncation = { path = "utils/output-truncation" } codex-utils-path = { path = "utils/path-utils" } codex-utils-path-uri = { path = "utils/path-uri" } codex-utils-plugins = { path = "utils/plugins" } +codex-utils-process = { path = "utils/process" } codex-utils-pty = { path = "utils/pty" } codex-utils-redacted-string = { path = "utils/redacted-string" } codex-utils-rustls-provider = { path = "utils/rustls-provider" } diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index ad0a5fbab362..8834c54b7ec5 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -25,6 +25,7 @@ doctest = false workspace = true [dependencies] +codex-utils-process = { workspace = true } anyhow = { workspace = true } base64 = { workspace = true } axum = { workspace = true, default-features = false, features = [ diff --git a/codex-rs/app-server/src/request_processors/feedback_doctor_report.rs b/codex-rs/app-server/src/request_processors/feedback_doctor_report.rs index 905f721f1d45..b01e490b8388 100644 --- a/codex-rs/app-server/src/request_processors/feedback_doctor_report.rs +++ b/codex-rs/app-server/src/request_processors/feedback_doctor_report.rs @@ -106,7 +106,7 @@ pub(crate) async fn doctor_feedback_report( } fn doctor_command(executable: &Path, cwd: &Path, codex_home: &Path) -> Command { - let mut command = Command::new(executable); + let mut command = Command::from(codex_utils_process::background_command(executable)); command .arg("--cd") .arg(cwd) diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs index 5d26c68e78ec..d21bebfc375c 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -131,7 +131,15 @@ requires_openai_auth = true }) .await?; let enabled = user_enablement.unwrap_or(app_enablement) && catalog_opt_in; - let expected = if enabled { &remote } else { &bundled }; + let expected: &[ModelPreset] = if enabled { + &remote + } else if catalog_opt_in { + // The rollout gate suppresses discovery, but an explicit catalog still + // prevents bundled models from being advertised for this provider. + &[] + } else { + &bundled + }; assert_eq!( response, ModelListResponse { diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 4fed7668d3a4..399230d3f4d1 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -23,6 +23,7 @@ doctest = false workspace = true [dependencies] +codex-utils-process = { workspace = true } anyhow = { workspace = true } chrono = { workspace = true } clap = { workspace = true, features = ["derive", "env"] } diff --git a/codex-rs/cli/src/doctor/desktop/windows_security.rs b/codex-rs/cli/src/doctor/desktop/windows_security.rs index 94e95edca8a1..d54f1b593668 100644 --- a/codex-rs/cli/src/doctor/desktop/windows_security.rs +++ b/codex-rs/cli/src/doctor/desktop/windows_security.rs @@ -72,7 +72,7 @@ async fn query_channel(wevtutil: &Path, channel: Channel) -> Option>() .join(" or "); - let mut child = Command::new(wevtutil) + let mut child = Command::from(codex_utils_process::background_command(wevtutil)) .args(["qe", channel.1]) .arg(format!( "/q:*[System[({events}) and TimeCreated[timediff(@SystemTime) <= 604800000]]]" diff --git a/codex-rs/cli/src/doctor/security.rs b/codex-rs/cli/src/doctor/security.rs index 0413f9c0a253..64141dd976be 100644 --- a/codex-rs/cli/src/doctor/security.rs +++ b/codex-rs/cli/src/doctor/security.rs @@ -236,7 +236,7 @@ async fn endpoint_products() -> EndpointInspection { #[cfg(any(target_os = "macos", target_os = "windows"))] async fn product_command(program: impl AsRef, args: &[&str]) -> Option { - let mut command = Command::new(program); + let mut command = Command::from(codex_utils_process::background_command(program)); command .args(args) .stdin(Stdio::null()) diff --git a/codex-rs/cli/tests/daemon_startup.rs b/codex-rs/cli/tests/daemon_startup.rs index be8752836ec9..67eff23d4d28 100644 --- a/codex-rs/cli/tests/daemon_startup.rs +++ b/codex-rs/cli/tests/daemon_startup.rs @@ -73,8 +73,17 @@ async fn restrictive_launcher_uses_embedded_if_daemon_cannot_start() -> Result<( .arg("--no-alt-screen") .stdin(std::process::Stdio::inherit()) .stdout(std::process::Stdio::inherit()) - .stderr(std::process::Stdio::inherit()); - let status = job.spawn_contained(&mut command)?.wait().await?; + .stderr(std::process::Stdio::inherit()) + // This interactive CLI must inherit the PTY console rather than use + // the background helper's CREATE_NO_WINDOW launch policy. + .creation_flags(windows_sys::Win32::System::Threading::CREATE_SUSPENDED) + .kill_on_drop(true); + let mut child = command.spawn()?; + ensure!( + job.assign_and_resume_process(child.id().context("missing CLI pid")?)?, + "CLI job assignment failed" + ); + let status = child.wait().await?; ensure!(status.success(), "CLI exited: {status}"); return Ok(()); } diff --git a/codex-rs/cli/tests/doctor_path_safety.rs b/codex-rs/cli/tests/doctor_path_safety.rs index c6bd5568701b..0b53ebb9b429 100644 --- a/codex-rs/cli/tests/doctor_path_safety.rs +++ b/codex-rs/cli/tests/doctor_path_safety.rs @@ -497,6 +497,7 @@ fn filesystem_probe_does_not_load_configuration() -> Result<()> { #[tokio::test] async fn interactive_tmux_startup_does_not_execute_workspace_helpers() -> Result<()> { let fixture = Fixture::new()?; + std::fs::create_dir(fixture.workspace.join(".codex"))?; let command = fixture.command()?; let mut env: std::collections::HashMap = std::env::vars().collect(); for (key, value) in command.get_envs() { diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index 812d0b4b9a62..d041d8ce109b 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -31,10 +31,7 @@ pub fn append_error_log(message: impl AsRef) { /// - trims trailing '/' /// - appends '/backend-api' for ChatGPT hosts when missing pub fn normalize_base_url(input: &str) -> String { - let mut base_url = input.to_string(); - while base_url.ends_with('/') { - base_url.pop(); - } + let mut base_url = input.trim_end_matches('/').to_string(); if (base_url.starts_with("https://chatgpt.com") || base_url.starts_with("https://chat.openai.com")) && !base_url.contains("/backend-api") @@ -171,3 +168,36 @@ pub fn format_relative_time(reference: DateTime, ts: DateTime) -> Stri pub fn format_relative_time_now(ts: DateTime) -> String { format_relative_time(Utc::now(), ts) } + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::normalize_base_url; + + #[test] + fn normalize_base_url_normalizes_urls() { + for (input, expected) in [ + ("https://example.com/path", "https://example.com/path"), + ("https://example.com/path/", "https://example.com/path"), + ("https://example.com/path///", "https://example.com/path"), + ("", ""), + ("///", ""), + ("https://chatgpt.com///", "https://chatgpt.com/backend-api"), + ( + "https://chatgpt.com/backend-api///", + "https://chatgpt.com/backend-api", + ), + ( + "https://chat.openai.com///", + "https://chat.openai.com/backend-api", + ), + ( + "https://chat.openai.com/backend-api///", + "https://chat.openai.com/backend-api", + ), + ] { + assert_eq!(normalize_base_url(input), expected, "input: {input:?}"); + } + } +} diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index 615d394a89cb..c4505879853e 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -1487,7 +1487,8 @@ pub async fn find_project_root( .unwrap_or_else(|| cwd.clone())) } -async fn discover_project_root( +/// Find a project marker, preserving absence instead of falling back to cwd. +pub async fn discover_project_root( fs: &dyn ExecutorFileSystem, cwd: &AbsolutePathBuf, project_root_markers: &[String], diff --git a/codex-rs/core-plugins/Cargo.toml b/codex-rs/core-plugins/Cargo.toml index af58b8e9e8aa..df9ec2752a27 100644 --- a/codex-rs/core-plugins/Cargo.toml +++ b/codex-rs/core-plugins/Cargo.toml @@ -13,6 +13,7 @@ path = "src/lib.rs" workspace = true [dependencies] +codex-utils-process = { workspace = true } anyhow = { workspace = true } codex-analytics = { workspace = true } codex-app-server-protocol = { workspace = true } diff --git a/codex-rs/core-plugins/src/git_policy.rs b/codex-rs/core-plugins/src/git_policy.rs index 27ba6998ae0c..35feafd0bd29 100644 --- a/codex-rs/core-plugins/src/git_policy.rs +++ b/codex-rs/core-plugins/src/git_policy.rs @@ -37,7 +37,7 @@ impl PluginGitMode { /// Creates a Git command with the repository-environment policy for this operation. /// Automatic remote lookups and initial clones must also select a trusted repository. pub(crate) fn command(self, git_binary: &Path) -> Command { - let mut command = Command::new(git_binary); + let mut command = codex_utils_process::background_command(git_binary); command.args(["-c", codex_git_utils::SAFE_BARE_REPOSITORY_CONFIG]); if matches!(self, Self::Automatic) { command.env("GIT_OPTIONAL_LOCKS", "0"); diff --git a/codex-rs/core-plugins/src/marketplace_add/install.rs b/codex-rs/core-plugins/src/marketplace_add/install.rs index 84cb7d973e46..2a5532ca0cf6 100644 --- a/codex-rs/core-plugins/src/marketplace_add/install.rs +++ b/codex-rs/core-plugins/src/marketplace_add/install.rs @@ -2,7 +2,6 @@ use super::MarketplaceAddError; use std::fs; use std::path::Path; use std::path::PathBuf; -use std::process::Command; pub(super) fn clone_git_source( url: &str, @@ -111,7 +110,7 @@ pub(super) fn marketplace_staging_root(install_root: &Path) -> PathBuf { } fn run_git(args: &[&str], cwd: Option<&Path>) -> Result<(), MarketplaceAddError> { - let mut command = Command::new("git"); + let mut command = codex_utils_process::background_command("git"); command .args(["-c", codex_git_utils::SAFE_BARE_REPOSITORY_CONFIG]) .args(args); diff --git a/codex-rs/core-plugins/src/npm_source.rs b/codex-rs/core-plugins/src/npm_source.rs index 3d728bd0f747..cb418edc7671 100644 --- a/codex-rs/core-plugins/src/npm_source.rs +++ b/codex-rs/core-plugins/src/npm_source.rs @@ -5,7 +5,6 @@ use std::ffi::OsStr; use std::fs; use std::path::Path; use std::path::PathBuf; -use std::process::Command; use tempfile::TempDir; const NPM_PLUGIN_SOURCE_STAGING_DIR: &str = "plugins/.marketplace-plugin-source-staging"; @@ -87,7 +86,7 @@ fn pack_npm_package( || package.to_string(), |version| format!("{package}@{version}"), ); - let mut command = Command::new(npm_command); + let mut command = codex_utils_process::background_command(npm_command); command .current_dir(destination) .arg("pack") diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 693fbd7b78d2..d5fd95d2de66 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -12,6 +12,7 @@ path = "src/lib.rs" workspace = true [dependencies] +codex-utils-process = { workspace = true } anyhow = { workspace = true } arc-swap = { workspace = true } async-channel = { workspace = true } diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index fcc6064f18cb..013a4faca473 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -428,7 +428,7 @@ async fn run_remote_compaction_request_v2( err, client_session, sess, - turn_context, + step_context, ResponsesStreamRequest::RemoteCompactionV2, ) .await?; diff --git a/codex-rs/core/src/responses_retry.rs b/codex-rs/core/src/responses_retry.rs index 64f5112382c2..c8edc8b9d4fb 100644 --- a/codex-rs/core/src/responses_retry.rs +++ b/codex-rs/core/src/responses_retry.rs @@ -1,9 +1,13 @@ //! Shared retry and transport fallback decisions for Responses requests. +//! Content-filter guidance is recorded for sampling requests before retry decisions. use std::time::Duration; use crate::client::ModelClientSession; +use crate::context::ContentFilterGuidance; +use crate::context::ContextualUserFragment; use crate::session::session::Session; +use crate::session::step_context::StepContext; use crate::session::turn_context::TurnContext; use codex_client::RetryOperation; use codex_features::Feature; @@ -55,9 +59,26 @@ pub(crate) async fn handle_response_stream_error( err: CodexErr, client_session: &mut ModelClientSession, sess: &Session, - turn_context: &TurnContext, + step_context: &StepContext, request: ResponsesStreamRequest, ) -> Result<(), CodexErr> { + let turn_context = &step_context.turn; + if matches!(request, ResponsesStreamRequest::Sampling) + && matches!(err.details(), CodexErrorDetails::ContentFilter) + { + let model_info = &step_context.settings.model_info; + let guidance = ContentFilterGuidance { + text: codex_prompts::ResolvedModelMessages::from_model(model_info) + .content_filter_guidance() + .to_string(), + }; + sess.record_conversation_items( + turn_context, + model_info, + &[ContextualUserFragment::into(guidance)], + ) + .await; + } let operation = match request { ResponsesStreamRequest::Sampling => RetryOperation::Sampling, ResponsesStreamRequest::RemoteCompactionV2 => RetryOperation::RemoteCompactionV2, diff --git a/codex-rs/core/src/responses_retry_tests.rs b/codex-rs/core/src/responses_retry_tests.rs index 85d40eb736cb..65aa29397c5f 100644 --- a/codex-rs/core/src/responses_retry_tests.rs +++ b/codex-rs/core/src/responses_retry_tests.rs @@ -3,9 +3,11 @@ use super::ResponsesStreamRetryState; use super::handle_response_stream_error; use super::log_retry; use crate::realtime_history::RealtimeHistoryState; +use crate::session::step_context::StepContext; use crate::session::tests::make_session_and_context; use codex_http_client::RetryAfter; use codex_protocol::error::CodexErr; +use std::sync::Arc; use std::time::Duration; use tokio::sync::Mutex; use tokio::time::Instant; @@ -56,6 +58,7 @@ async fn sampling_retry_logs_stream_error_context() { )] async fn stream_retry_preserves_deadline_across_delayed_notification() { let (mut session, turn_context) = make_session_and_context().await; + let step_context = StepContext::for_test(Arc::new(turn_context)); session.realtime_history = Some(Mutex::new(RealtimeHistoryState::default())); let mut client_session = session.services.model_client.new_session(); // The second retry emits a notification even when release builds hide the first. @@ -76,7 +79,7 @@ async fn stream_retry_preserves_deadline_across_delayed_notification() { CodexErr::InternalServerError.with_retry_after(advice), &mut client_session, &session, - &turn_context, + &step_context, ResponsesStreamRequest::Sampling, ); tokio::pin!(retry); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 902d18377cb9..e08a0fa5a8d5 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -659,6 +659,11 @@ impl Session { config.http_client_factory(), ) .await; + if model.trim().is_empty() { + return Err(CodexErr::InvalidRequest( + "No models are available. Set `model` explicitly or check your model catalog configuration.".to_string(), + )); + } let trusted_guardian_reviewer = crate::guardian::is_basic_session_source(&session_source) && !matches!(conversation_history, InitialHistory::Resumed(_)); if config diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 5d440240e835..13ebc68245dc 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3568,16 +3568,19 @@ async fn turn_start_lifecycle_exposes_turn_metadata_and_token_baseline() { #[tokio::test] async fn turn_error_lifecycle_exposes_error_and_stores() { + use codex_protocol::error::CodexErrKind; + struct SessionTurnErrorMarker; struct ThreadTurnErrorMarker; - #[derive(Debug, PartialEq, Eq)] + #[derive(Clone, Debug, PartialEq, Eq)] struct RecordedTurnError { session_level_id: String, thread_level_id: String, turn_level_id: String, turn_id: String, error: CodexErrorInfo, + error_kind: CodexErrKind, saw_session_store: bool, saw_thread_store: bool, } @@ -3601,6 +3604,7 @@ async fn turn_error_lifecycle_exposes_error_and_stores() { turn_level_id: input.turn_store.level_id().to_string(), turn_id: input.turn_id.to_string(), error: input.error, + error_kind: input.error_details.into(), saw_session_store: input .session_store .get::() @@ -3636,12 +3640,30 @@ async fn turn_error_lifecycle_exposes_error_and_stores() { turn_level_id: turn_context.sub_id.clone(), turn_id: turn_context.sub_id.clone(), error: CodexErrorInfo::UsageLimitExceeded, + error_kind: CodexErrKind::QuotaExceeded, saw_session_store: true, saw_thread_store: true, }; session - .emit_turn_error_lifecycle(&turn_context, CodexErrorInfo::UsageLimitExceeded) + .emit_turn_error_lifecycle( + &turn_context, + CodexErrorInfo::UsageLimitExceeded, + &CodexErrorDetails::QuotaExceeded, + ) + .await; + + let expected_override = RecordedTurnError { + error: CodexErrorInfo::BadRequest, + error_kind: CodexErrKind::InvalidImageRequest, + ..expected.clone() + }; + session + .emit_turn_error_lifecycle( + &turn_context, + CodexErrorInfo::BadRequest, + &CodexErrorDetails::InvalidImageRequest(), + ) .await; let actual = records @@ -3649,7 +3671,7 @@ async fn turn_error_lifecycle_exposes_error_and_stores() { .expect("turn error records lock") .drain(..) .collect::>(); - assert_eq!(vec![expected], actual); + assert_eq!(vec![expected, expected_override], actual); } #[tokio::test] diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 53a37b7729c0..5b335c2c0572 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -11,7 +11,6 @@ use crate::compact::InitialContextInjection; use crate::compact::run_inline_auto_compact_task; use crate::compact_remote_v2::run_inline_remote_auto_compact_task as run_inline_remote_auto_compact_task_v2; use crate::connectors; -use crate::context::ContentFilterGuidance; use crate::context::ContextualUserFragment; use crate::context::UserVerificationNotice; use crate::environment_selection::TurnEnvironmentSnapshot; @@ -205,7 +204,7 @@ pub(crate) async fn run_turn( return Err(err); } let error = err.to_codex_protocol_error(); - sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) + sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone(), err.details()) .await; // Publish the failure only after prompt hooks finish, so clients cannot react to // an error by steering follow-up input into a turn still preserving its prompt. @@ -630,8 +629,12 @@ pub(crate) async fn run_turn( return Err(err); } let error = err.to_codex_protocol_error(); - sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) - .await; + sess.emit_turn_error_lifecycle( + turn_context.as_ref(), + error.clone(), + err.details(), + ) + .await; return Ok(None); } if run_pending_session_start_hooks(&sess, &turn_context).await { @@ -740,8 +743,12 @@ pub(crate) async fn run_turn( let error = err.to_codex_protocol_error(); if matches!(error, CodexErrorInfo::UsageLimitExceeded) { // Preserve the completed answer while stopping automatic work. - sess.emit_turn_error_lifecycle(turn_context.as_ref(), error) - .await; + sess.emit_turn_error_lifecycle( + turn_context.as_ref(), + error, + err.details(), + ) + .await; } warn!(error = %err, "Post-turn compaction failed; preserving the completed turn"); } @@ -794,8 +801,12 @@ pub(crate) async fn run_turn( { sess.track_turn_codex_error(turn_context.as_ref(), &codex_error); let error = CodexErrorInfo::BadRequest; - sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) - .await; + sess.emit_turn_error_lifecycle( + turn_context.as_ref(), + error.clone(), + codex_error.details(), + ) + .await; let event = EventMsg::Error(ErrorEvent { misalignment: None, message: "Invalid image in your last message. Please remove it and try again." @@ -814,7 +825,7 @@ pub(crate) async fn run_turn( sess.conversation.retire_handoffs_for_misalignment().await; } let error = e.to_codex_protocol_error(); - sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone()) + sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone(), e.details()) .await; sess.track_turn_codex_error(turn_context.as_ref(), &e); let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None)); @@ -1698,33 +1709,15 @@ async fn run_sampling_request( let original_input = original_input.get_or_insert(prompt.input); - let retry = async { - if matches!(err.details(), CodexErrorDetails::ContentFilter) { - let model_info = &step_context.settings.model_info; - let guidance = ContentFilterGuidance { - text: codex_prompts::ResolvedModelMessages::from_model(model_info) - .content_filter_guidance() - .to_string(), - }; - sess.record_conversation_items( - &turn_context, - model_info, - &[ContextualUserFragment::into(guidance)], - ) - .await; - } - - handle_response_stream_error( - &mut retry_state, - max_retries, - err, - client_session, - &sess, - &turn_context, - ResponsesStreamRequest::Sampling, - ) - .await - } + let retry = handle_response_stream_error( + &mut retry_state, + max_retries, + err, + client_session, + &sess, + &step_context, + ResponsesStreamRequest::Sampling, + ) .or_cancel(&preempt) .or_cancel(&cancellation_token) .await?; diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index 596bf8de5a75..994506d24c6a 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -1067,7 +1067,7 @@ async fn run_script_with_timeout( // Handler is kept as guard to control the drop. The `mut` pattern is required because .args() // returns a ref of handler. - let mut handler = Command::new(&args[0]); + let mut handler = Command::from(codex_utils_process::background_command(&args[0])); handler.args(&args[1..]); handler.stdin(Stdio::null()); handler.current_dir(cwd); diff --git a/codex-rs/core/src/spawn.rs b/codex-rs/core/src/spawn.rs index bbae9308d7e8..79526b8bfde2 100644 --- a/codex-rs/core/src/spawn.rs +++ b/codex-rs/core/src/spawn.rs @@ -67,7 +67,12 @@ pub(crate) async fn spawn_child_async(request: SpawnChildRequest<'_>) -> std::io "spawn_child_async: {program:?} {args:?} {arg0:?} {cwd:?} {network_sandbox_policy:?} {stdio_policy:?} {env:?}" ); - let mut cmd = Command::new(&program); + let mut cmd = match stdio_policy { + StdioPolicy::RedirectForShellTool => { + Command::from(codex_utils_process::background_command(&program)) + } + StdioPolicy::Inherit => Command::new(&program), + }; #[cfg(unix)] cmd.arg0(arg0.map_or_else(|| program.to_string_lossy().to_string(), String::from)); cmd.args(args); diff --git a/codex-rs/core/src/tasks/compact.rs b/codex-rs/core/src/tasks/compact.rs index 85f1a54f918a..4235fb6de109 100644 --- a/codex-rs/core/src/tasks/compact.rs +++ b/codex-rs/core/src/tasks/compact.rs @@ -75,7 +75,9 @@ impl SessionTask for CompactTask { let error = err.to_codex_protocol_error(); if matches!(error, CodexErrorInfo::UsageLimitExceeded) { // Compaction already emitted the error; notify extensions without emitting it twice. - session.emit_turn_error_lifecycle(ctx.as_ref(), error).await; + session + .emit_turn_error_lifecycle(ctx.as_ref(), error, err.details()) + .await; } } Ok(None) diff --git a/codex-rs/core/src/tasks/lifecycle.rs b/codex-rs/core/src/tasks/lifecycle.rs index 27835be8fc37..afecaede5abc 100644 --- a/codex-rs/core/src/tasks/lifecycle.rs +++ b/codex-rs/core/src/tasks/lifecycle.rs @@ -4,6 +4,7 @@ use codex_analytics::TurnAnalyticsMetadata; use codex_extension_api::ExtensionData; use codex_extension_api::ThreadIdleCause; use codex_extension_api::TurnStartPhase; +use codex_protocol::error::CodexErrorDetails; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TurnAbortReason; @@ -103,12 +104,14 @@ impl Session { &self, turn_context: &TurnContext, error: CodexErrorInfo, + error_details: &CodexErrorDetails, ) { for contributor in self.services.extensions.turn_lifecycle_contributors() { contributor .on_turn_error(codex_extension_api::TurnErrorInput { turn_id: turn_context.sub_id.as_str(), error: error.clone(), + error_details, session_store: &self.services.session_extension_data, thread_store: &self.services.thread_extension_data, turn_store: turn_context.extension_data.as_ref(), diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 323c8015a5a9..28a3b54b1c6f 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -641,6 +641,7 @@ impl Session { self.emit_turn_error_lifecycle( turn_context.as_ref(), err.to_codex_protocol_error(), + err.details(), ) .await; self.track_turn_codex_error(turn_context.as_ref(), &err); diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index ada5d56124fd..50f965cd5f0f 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -216,6 +216,8 @@ mod tool_lifecycle; mod tool_parallelism; mod tools; mod truncation; +#[path = "turn_error_details_tests.rs"] +mod turn_error_details; mod turn_input_submission; mod turn_state; mod unified_exec; diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index dad188d70cda..17ef6b329756 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -946,6 +946,7 @@ async fn remote_models_apply_legacy_instructions(auth: CodexAuth) -> Result<()> cwd, config, thread_manager, + home: _home, .. } = builder.build(&server).await?; @@ -1484,6 +1485,51 @@ fn test_remote_model_with_policy( } } +#[test_case(None; "without explicit model")] +#[test_case(Some("gateway-conversation"); "with explicit model")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn empty_model_catalog_requires_explicit_model(configured_model: Option<&str>) -> Result<()> { + skip_if_no_network!(Ok(())); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/codex/models")) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(ModelsResponse::default())) + .expect(1) + .mount(&server) + .await; + let response = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + let catalog_url = format!("{}/codex/models", server.uri()); + let model = configured_model.map(str::to_string); + let result = test_codex() + .with_auth(CodexAuth::from_api_key("gateway-api-key")) + .with_config(move |config| { + config.model = model; + config.model_provider.model_catalog_url = Some(catalog_url.into()); + config + .features + .enable(Feature::ApiKeyModelDiscovery) + .expect("enable API-key discovery"); + }) + .build_with_auto_env(&server) + .await; + if let Some(model) = configured_model { + let test = result?; + test.submit_turn("hello").await?; + assert_eq!(response.single_request().body_json()["model"], model); + } else { + let error = result.err().expect("startup requires a model"); + assert_eq!( + error.to_string(), + "No models are available. Set `model` explicitly or check your model catalog configuration." + ); + } + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn model_catalog_url_supplies_conversation_model_and_instructions() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/scenarios.rs b/codex-rs/core/tests/suite/scenarios.rs index 43ab5ec6bdd3..9f2e33dbfbb0 100644 --- a/codex-rs/core/tests/suite/scenarios.rs +++ b/codex-rs/core/tests/suite/scenarios.rs @@ -130,6 +130,9 @@ mod mxc; #[path = "scenarios_tools_namespace_budget.rs"] mod tools_namespace_budget; +#[path = "scenarios_skill_catalog_dedup.rs"] +mod skill_catalog_dedup; + fn skills_extensions() -> Arc> { let mut extensions = ExtensionRegistryBuilder::::new(); install(&mut extensions, |config: &Config| SkillsExtensionConfig { diff --git a/codex-rs/core/tests/suite/scenarios_skill_catalog_dedup.rs b/codex-rs/core/tests/suite/scenarios_skill_catalog_dedup.rs new file mode 100644 index 000000000000..155b207161a8 --- /dev/null +++ b/codex-rs/core/tests/suite/scenarios_skill_catalog_dedup.rs @@ -0,0 +1,151 @@ +//! Snapshots cloud-preferred skill deduplication and stable executor aliases in model requests. + +use std::sync::Arc; + +use anyhow::Result; +use codex_core::config::Config; +use codex_extension_api::ExtensionRegistryBuilder; +use codex_features::Feature; +use codex_skills_extension::SkillProviders; +use codex_skills_extension::SkillsExtensionConfig; +use codex_skills_extension::catalog::SkillAuthority; +use codex_skills_extension::catalog::SkillCatalog; +use codex_skills_extension::catalog::SkillCatalogEntry; +use codex_skills_extension::catalog::SkillPackageId; +use codex_skills_extension::catalog::SkillResourceId; +use codex_skills_extension::catalog::SkillSourceKind; +use codex_skills_extension::install_with_providers; +use core_test_support::context_snapshot; +use core_test_support::context_snapshot::ContextSnapshotOptions; +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use pretty_assertions::assert_eq; + +use super::super::skills_extension::CatalogSkillProvider; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cloud_preference_preserves_executor_aliases_and_description_budget() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let roots = [ + format!("skill://executor-a/{}", "a".repeat(180)), + format!("skill://executor-b/{}", "b".repeat(180)), + ]; + let description = format!("{} UNIQUE_DESCRIPTION_END", "Skill guidance. ".repeat(60)); + let mut executor_entries = Vec::new(); + for (plugin, root) in ["demo", "other"].into_iter().zip(&roots) { + for index in 0..3 { + let package = format!("{root}/s{index}"); + executor_entries.push( + SkillCatalogEntry::new( + SkillPackageId(package.clone()), + SkillAuthority::new(SkillSourceKind::Executor, "executor"), + format!("{plugin}:s{index}"), + &description, + SkillResourceId::new(format!("{package}/SKILL.md")), + ) + .with_alias_root(root), + ); + } + } + let cloud_entries = (0..3) + .map(|index| { + SkillCatalogEntry::new( + SkillPackageId(format!("skill://cloud/s{index}")), + SkillAuthority::new(SkillSourceKind::Cloud, "cloud"), + format!("demo:s{index}"), + "Cloud skill instructions.", + SkillResourceId::new(format!("skill://cloud/s{index}/SKILL.md")), + ) + }) + .collect(); + let provider = |entries| { + Arc::new(CatalogSkillProvider { + catalog: SkillCatalog { + entries, + warnings: Vec::new(), + }, + }) + }; + let mut extensions = ExtensionRegistryBuilder::new(); + install_with_providers( + &mut extensions, + SkillProviders::new() + .with_executor_provider(provider(executor_entries)) + .with_cloud_provider(provider(cloud_entries)), + |config: &Config| SkillsExtensionConfig { + include_instructions: config.include_skill_instructions, + max_context_tokens: config.skill_max_context_tokens, + bundled_skills_enabled: false, + cloud_skill_enabled: true, + shadow_selection_enabled: false, + }, + ); + let mock = responses::mount_sse_sequence( + &server, + (0..2) + .map(|index| { + responses::sse(vec![ + responses::ev_assistant_message( + &format!("message-{index}"), + "Skills are available.", + ), + responses::ev_completed(&format!("response-{index}")), + ]) + }) + .collect(), + ) + .await; + let test = test_codex() + .with_exec_server_url("none") + .with_extensions(Arc::new(extensions.build())) + .with_model_info_override("gpt-6-astra", |model| { + model.context_window = Some(70_000); + model.max_context_window = None; + model.include_skills_usage_instructions = false; + }) + .with_config(|config| { + super::configure_scenario_catalog(config); + config.cloud_skill_enabled = true; + config + .features + .enable(Feature::ExecutorCapabilityDiscovery) + .expect("enable executor capability discovery"); + }) + .build_with_auto_env(&server) + .await?; + test.submit_turn("Which skills are available?").await?; + test.submit_turn("Are those same skills still available?") + .await?; + + let requests = mock.requests(); + assert_eq!(requests.len(), 2); + for (request_index, request) in requests.iter().enumerate() { + let body = request.message_input_texts("developer").join("\n"); + for index in 0..3 { + assert_eq!( + body.matches(&format!("- demo:s{index}:")).count(), + 1, + "request {request_index}" + ); + assert!(body.contains(&format!("(cloud package: skill://cloud/s{index})"))); + assert!(body.contains(&format!("(executor package: e1/s{index})"))); + assert!(!body.contains(&format!("(executor package: e0/s{index})"))); + } + for (index, root) in roots.iter().enumerate() { + assert!(body.contains(&format!("- `e{index}` = `{root}`"))); + } + assert_eq!(body.matches("UNIQUE_DESCRIPTION_END").count(), 3); + } + insta::assert_snapshot!( + "dedup_before_budgeting", + context_snapshot::format_request_history_snapshot( + "Cloud skills replace duplicate executor listings before budgeting; unique executor skills retain their descriptions and e1 aliases across turns.", + &requests, + &ContextSnapshotOptions::default().include_request_settings(), + ) + ); + Ok(()) +} diff --git a/codex-rs/core/tests/suite/skills_extension.rs b/codex-rs/core/tests/suite/skills_extension.rs index ec7e757581cb..5f116a99d15a 100644 --- a/codex-rs/core/tests/suite/skills_extension.rs +++ b/codex-rs/core/tests/suite/skills_extension.rs @@ -128,8 +128,8 @@ struct StaticSkillProvider { main_prompt_contents: Option, } -struct CatalogSkillProvider { - catalog: SkillCatalog, +pub(super) struct CatalogSkillProvider { + pub(super) catalog: SkillCatalog, } struct PausedCatalogSkillProvider { diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__skill_catalog_dedup__dedup_before_budgeting.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__skill_catalog_dedup__dedup_before_budgeting.snap new file mode 100644 index 000000000000..4a1c05002d50 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__skill_catalog_dedup__dedup_before_budgeting.snap @@ -0,0 +1,125 @@ +--- +source: core/tests/suite/scenarios_skill_catalog_dedup.rs +expression: "context_snapshot::format_request_history_snapshot(\"Cloud skills replace duplicate executor listings before budgeting; unique executor skills retain their descriptions and e1 aliases across turns.\",\n&requests, &ContextSnapshotOptions::default().include_request_settings(),)" +--- +Scenario: Cloud skills replace duplicate executor listings before budgeting; unique executor skills retain their descriptions and e1 aliases across turns. + +## Window 1 +Settings: + include: ["reasoning.encrypted_content"] + model: "gpt-6-astra" + parallel_tool_calls: false + prompt_cache_key: "" + reasoning: {"context":"all_turns","effort":"low"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" +-- request 1 (turn) -- +00:additional_tools/developer (3; hash=c803bbbd445f67cc): + - namespace/functions; hash=48b2ce07cd26a39b + - custom/exec + - function/wait + - function/request_user_input + - function/request_user_input_async + - namespace/clock: Tools for reading and waiting on time.; hash=f402e8c5e9b5e317 + - function/sleep + - namespace/collaboration: Tools for spawning and managing sub-agents.; hash=fd994b11525da73c + - function/followup_task + - function/interrupt_agent + - function/list_agents + - function/send_message + - function/spawn_agent + - function/wait_agent +01:message/developer: + You are Codex, an agent based on GPT-6. You and the user share one workspace, and your job is to collabo...them until their intended goal is completely handled. [hash=efa50f074efa5803] + + # When to ask the user for permission + + Use your best judgement given task context for when you really need user permission, like a competent co...ork without ending the turn to clarify with the user. [hash=ca7367e658b51388] + + User authorization and preferences persist across turns. Do not request permission again when the user h... any guidelines provided in skills or external files. [hash=911f91c92c72882c] + + + + - Skill naming: If a plugin contributes skills, those skill entries are prefixed with plugin_name: in the Skills list. + - MCP naming: Plugin-provided MCP tools keep standard MCP identifiers such as mcp__server__tool; use tool provenance to tell which plugin they come from. + - Trigger rules: If the user explicitly names a plugin, prefer capabilities associated with that plugin for that turn. + - Relationship to capabilities: Plugins are not invoked directly. Use their underlying skills, MCP tools, and app tools to help solve the task. + - Relevance: Determine what a plugin can help with from explicit user mention or from the plugin-associa..., MCP tools, and apps exposed elsewhere in this turn. [hash=620459a4e7b590f0] + - Missing/blocked: If the user requests a plugin that does not have relevant callable capabilities for t..., say so briefly and continue with the best fallback. [hash=746b0469a8f6cb3e] + +02:message/developer[4]: + [01] + Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `danger-full-access`...ll commands are permitted. Network access is enabled. [hash=c134061cebb94a1b] + Approval policy is currently never. Do not provide the `sandbox_permissions` for any reason, commands will be rejected. + + [02] # Collaboration Mode: Default + + You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active. + + Your active mode changes only when new developer instructions with a different `...<...by themselves. Known mode names are Default and Plan. [hash=e75f6b12fc52d812] + + ## request_user_input availability + + Use the `request_user_input` tool only when it is listed in the available tools for this turn. + + Use the `request_user_input` tool only for optional questions where the answer would materially improve the quality of the work. + + If `request_user_input` returns no answers, continue with best judgment instead of asking again or treating the turn as blocked. + + Never use the `request_user_input` tool for permission requests or permission-related escalations. + + [03] + ## Skills + A skill is a set of instructions provided through a `SKILL.md` source. Below is the list of skills that ...locators can be expanded using the skill roots table. [hash=f00d6a4d3e192f06] + ### Skill roots + - `e0` = `skill://executor-a/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa` [hash=89039f2551ec08fb] + - `e1` = `skill://executor-b/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb...bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb` [hash=aec7776011a3143f] + Read a skill package directly with `skills.read({"package":""})` to read its `SKILL.md`; root a...ackage is not provided, use `skills.list` to find it. [hash=25bf84ea2123d647] + ### Available skills + - other:s0: Skill guidance. Skill guidance. Skill guidance. Skill guidance. Skill guidance. Skill guidan...ce. UNIQUE_DESCRIPTION_END (executor package: e1/s0) [hash=1ce5d9766079d21b] + - other:s1: Skill guidance. Skill guidance. Skill guidance. Skill guidance. Skill guidance. Skill guidan...ce. UNIQUE_DESCRIPTION_END (executor package: e1/s1) [hash=d8432891813affeb] + - other:s2: Skill guidance. Skill guidance. Skill guidance. Skill guidance. Skill guidance. Skill guidan...ce. UNIQUE_DESCRIPTION_END (executor package: e1/s2) [hash=1c05fa218dd21e8f] + + [04] + ## Skills + A skill is a set of instructions provided through a `SKILL.md` source. Below is the list of skills that ...urce` locators use their provider's access mechanism. [hash=b6782e3abdba03b2] + Read a skill package directly with `skills.read({"package":""})` to read its `SKILL.md`; root a...ackage is not provided, use `skills.list` to find it. [hash=25bf84ea2123d647] + ### Available skills + - demo:s0: Cloud skill instructions. (cloud package: skill://cloud/s0) + - demo:s1: Cloud skill instructions. (cloud package: skill://cloud/s1) + - demo:s2: Cloud skill instructions. (cloud package: skill://cloud/s2) + +03:message/developer: + You are `/root`, the primary agent in a team of agents collaborating to fulfill the user's goals. + + At the start of your turn, you are the active agent. + You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents. + All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools. + + You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task an...message to a running agent without triggering a turn. [hash=7d40ff8b58977202] + `send_message` calls may be read by a human, so ensure they are legible. Always put proper spaces between words and/or numbers. + + - All agents use the same current working directory. + - As a result, edits made by one agent are immediately visible to all other agents. + + When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling. + + There are 4 available concurrency slots, meaning that up to 4 agents can be active at once, including you. + + Full-history forks (`fork_turns` omitted or `"all"`) inherit the parent model and reasoning effort and d...ne"` or a positive integer string. [hash=c7f52e90688299ce] +04:message/developer: + Any earlier instruction enabling proactive multi-agent delegation no longer applies. D...elegation, or parallel agent work. [hash=2b6e037067b4390c] +05:message/user: + + + + +06:message/user: + Which skills are available? +-- request 2 (turn) -- +07:message/assistant: + Skills are available. +08:message/user: + Are those same skills still available? diff --git a/codex-rs/core/tests/suite/turn_error_details_tests.rs b/codex-rs/core/tests/suite/turn_error_details_tests.rs new file mode 100644 index 000000000000..0cc0c8c8dbc2 --- /dev/null +++ b/codex-rs/core/tests/suite/turn_error_details_tests.rs @@ -0,0 +1,182 @@ +//! Verify error hooks retain backend details without changing compaction outcomes. + +use codex_core::TurnInputRequest; +use codex_extension_api::ExtensionFuture; +use codex_extension_api::ExtensionRegistryBuilder; +use codex_extension_api::TurnErrorInput; +use codex_extension_api::TurnLifecycleContributor; +use codex_protocol::error::CodexErrorDetails; +use codex_protocol::protocol::CodexErrorInfo; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::RateLimitReachedType; +use codex_protocol::user_input::UserInput; +use core_test_support::responses; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::sync::Arc; +use std::sync::Mutex; +use wiremock::ResponseTemplate; + +#[derive(Debug, PartialEq)] +struct ObservedUsageLimit { + turn_id: String, + error: CodexErrorInfo, + resets_at: Option, + limit_window_minutes: Option, + limit_id: Option, + snapshot_resets_at: Option, + rate_limit_reached_type: Option, +} + +struct ErrorRecorder(Arc>>); + +impl TurnLifecycleContributor for ErrorRecorder { + fn on_turn_error<'a>(&'a self, input: TurnErrorInput<'a>) -> ExtensionFuture<'a, ()> { + Box::pin(async move { + let CodexErrorDetails::UsageLimitReached(details) = input.error_details else { + panic!("expected usage-limit details"); + }; + let snapshot = details.rate_limits.as_ref(); + self.0 + .lock() + .expect("usage limit records lock") + .push(ObservedUsageLimit { + turn_id: input.turn_id.to_string(), + error: input.error, + resets_at: details.resets_at.map(|reset| reset.timestamp()), + limit_window_minutes: details.limit_window_minutes, + limit_id: snapshot.and_then(|snapshot| snapshot.limit_id.clone()), + snapshot_resets_at: snapshot + .and_then(|snapshot| snapshot.primary.as_ref()) + .and_then(|window| window.resets_at), + rate_limit_reached_type: details.rate_limit_reached_type, + }); + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Phase { + Sampling, + ManualCompaction, + PostTurnCompaction, +} + +/// Backend reset details reach the hook even when post-turn compaction preserves a completed answer. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn turn_error_details_preserve_usage_reset_and_compaction_outcomes() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + for phase in [ + Phase::Sampling, + Phase::ManualCompaction, + Phase::PostTurnCompaction, + ] { + let server = responses::start_mock_server().await; + let denial = ResponseTemplate::new(429) + .insert_header("x-codex-active-limit", "test_limit") + .insert_header("x-test-limit-primary-used-percent", "100") + .insert_header("x-test-limit-primary-reset-at", "1700000000") + .insert_header("x-codex-rate-limit-reached-type", "rate_limit_reached") + .set_body_json(json!({ + "error": { + "type": "usage_limit_reached", + "resets_at": 1800000000, + "limit_window_minutes": 10080 + } + })); + let mut responses = Vec::new(); + if phase == Phase::PostTurnCompaction { + responses.push(responses::sse_response(responses::sse(vec![ + responses::ev_response_created("answer"), + responses::ev_assistant_message("answer-message", "Completed answer"), + responses::ev_completed_with_tokens("answer", /*total_tokens*/ 7_000), + ]))); + } + responses.push(denial); + let expected_requests = responses.len(); + let mock = responses::mount_response_sequence(&server, responses).await; + let observed = Arc::new(Mutex::new(Vec::new())); + let mut extensions = ExtensionRegistryBuilder::new(); + extensions.turn_lifecycle_contributor(Arc::new(ErrorRecorder(observed.clone()))); + let mut builder = test_codex() + .with_extensions(Arc::new(extensions.build())) + .with_model_info_override("gpt-5.5", |info| { + info.context_window = Some(12_000); + info.max_context_window = None; + }) + .with_config(move |config| { + config.model_provider.name = "Local compaction test provider".to_string(); + config.model_provider.request_max_retries = Some(0); + config.model_provider.stream_max_retries = Some(0); + config.model_auto_compact_token_limit = Some(100_000); + config.model_post_turn_compact_threshold_percent = + if phase == Phase::PostTurnCompaction { + 50 + } else { + 0 + }; + }); + let test = builder.build_with_auto_env(&server).await?; + if phase == Phase::ManualCompaction { + test.codex.submit(Op::Compact).await?; + } else { + test.codex + .start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text { + text: "Answer the request".to_string(), + text_elements: Vec::new(), + }])) + .await?; + } + + let mut errors = Vec::new(); + let completed = wait_for_event(&test.codex, |event| { + if let EventMsg::Error(error) = event { + errors.push(error.codex_error_info.clone()); + } + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + let EventMsg::TurnComplete(completed) = completed else { + unreachable!() + }; + assert_eq!( + *observed.lock().expect("usage limit records lock"), + vec![ObservedUsageLimit { + turn_id: completed.turn_id.clone(), + error: CodexErrorInfo::UsageLimitExceeded, + resets_at: Some(1_800_000_000), + limit_window_minutes: Some(10_080), + limit_id: Some("test_limit".to_string()), + snapshot_resets_at: Some(1_700_000_000), + rate_limit_reached_type: Some(RateLimitReachedType::RateLimitReached), + }], + "{phase:?}", + ); + let expected_error = + (phase != Phase::PostTurnCompaction).then_some(CodexErrorInfo::UsageLimitExceeded); + assert_eq!( + completed.error.and_then(|error| error.codex_error_info), + expected_error, + "{phase:?}" + ); + assert_eq!( + errors, + expected_error.map(Some).into_iter().collect::>(), + "{phase:?}" + ); + if phase == Phase::PostTurnCompaction { + assert_eq!( + completed.last_agent_message.as_deref(), + Some("Completed answer") + ); + } + assert_eq!(mock.requests().len(), expected_requests); + test.codex.shutdown_and_wait().await?; + } + Ok(()) +} diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index fd9928db363b..3c78a5021c49 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -19,6 +19,7 @@ ignored-paths = [ workspace = true [dependencies] +codex-utils-process = { workspace = true } arc-swap = { workspace = true } axum = { workspace = true, features = ["http1", "tokio", "ws"] } base64 = { workspace = true } diff --git a/codex-rs/exec-server/src/client_transport.rs b/codex-rs/exec-server/src/client_transport.rs index a8a5d7967e58..beb47a1c3766 100644 --- a/codex-rs/exec-server/src/client_transport.rs +++ b/codex-rs/exec-server/src/client_transport.rs @@ -838,7 +838,9 @@ fn is_rendezvous_harness_url(websocket_url: &str) -> bool { } fn stdio_command_process(stdio_command: &StdioExecServerCommand) -> Command { - let mut command = Command::new(&stdio_command.program); + let mut command = Command::from(codex_utils_process::background_command( + &stdio_command.program, + )); command.args(&stdio_command.args); command.envs(&stdio_command.env); scrub_non_inheritable_env_vars(command.as_std_mut()); diff --git a/codex-rs/exec-server/src/connection.rs b/codex-rs/exec-server/src/connection.rs index 476aaf3619b1..e18faffd1ebf 100644 --- a/codex-rs/exec-server/src/connection.rs +++ b/codex-rs/exec-server/src/connection.rs @@ -252,7 +252,7 @@ fn kill_direct_child(child_process: &mut Child, action: &str) { #[cfg(windows)] fn kill_windows_process_tree(pid: u32) -> bool { let pid = pid.to_string(); - match std::process::Command::new("taskkill") + match codex_utils_process::background_command("taskkill") .args(["/PID", pid.as_str(), "/T", "/F"]) .stdin(Stdio::null()) .stdout(Stdio::null()) diff --git a/codex-rs/ext/extension-api/src/contributors/turn_lifecycle.rs b/codex-rs/ext/extension-api/src/contributors/turn_lifecycle.rs index ce9e85493029..7361c20c5fb2 100644 --- a/codex-rs/ext/extension-api/src/contributors/turn_lifecycle.rs +++ b/codex-rs/ext/extension-api/src/contributors/turn_lifecycle.rs @@ -1,6 +1,7 @@ //! Turn lifecycle inputs and scheduling phases for host-owned contributors. use codex_protocol::config_types::CollaborationMode; +use codex_protocol::error::CodexErrorDetails; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TurnAbortReason; @@ -60,6 +61,8 @@ pub struct TurnErrorInput<'a> { pub turn_id: &'a str, /// Error surfaced by the host for this turn. pub error: CodexErrorInfo, + /// Original error details, including backend metadata omitted from the public category. + pub error_details: &'a CodexErrorDetails, /// Store scoped to the host session runtime. pub session_store: &'a ExtensionData, /// Store scoped to this thread runtime. diff --git a/codex-rs/ext/goal/tests/goal_extension_backend.rs b/codex-rs/ext/goal/tests/goal_extension_backend.rs index 4b41740f893b..e0a952d1a8b0 100644 --- a/codex-rs/ext/goal/tests/goal_extension_backend.rs +++ b/codex-rs/ext/goal/tests/goal_extension_backend.rs @@ -38,7 +38,7 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; -use codex_protocol::protocol::CodexErrorInfo; +use codex_protocol::error::CodexErr; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::SessionSource; @@ -786,7 +786,7 @@ async fn turn_error_usage_limit_accounts_progress_and_clears_accounting() -> any ) .await; harness - .notify_turn_error("turn-1", CodexErrorInfo::UsageLimitExceeded) + .notify_turn_error("turn-1", CodexErr::UsageNotIncluded) .await; let goal = runtime @@ -857,7 +857,7 @@ async fn turn_error_blocks_goal() -> anyhow::Result<()> { .await?; harness - .notify_turn_error("turn-1", CodexErrorInfo::Other) + .notify_turn_error("turn-1", CodexErr::Fatal("test error".to_string())) .await; let goal = runtime @@ -1887,13 +1887,14 @@ impl GoalExtensionHarness { } } - async fn notify_turn_error(&self, turn_id: &str, error: CodexErrorInfo) { + async fn notify_turn_error(&self, turn_id: &str, error: CodexErr) { let turn_store = ExtensionData::new(turn_id); for contributor in self.registry.turn_lifecycle_contributors() { contributor .on_turn_error(TurnErrorInput { turn_id, - error: error.clone(), + error: error.to_codex_protocol_error(), + error_details: error.details(), session_store: &self.session_store, thread_store: &self.thread_store, turn_store: &turn_store, diff --git a/codex-rs/ext/skills/src/render.rs b/codex-rs/ext/skills/src/render.rs index ede504ada01b..cfd65d61ba82 100644 --- a/codex-rs/ext/skills/src/render.rs +++ b/codex-rs/ext/skills/src/render.rs @@ -474,6 +474,10 @@ pub(crate) struct RenderedSkillCatalogs { pub(crate) host: Option, } +#[path = "render_dedup.rs"] +mod dedup; +pub(crate) use dedup::PreparedSkillCatalog; + impl AvailableSkillsRender { /// Count the metadata charged by the allocator, including aliases and omission notices. pub(crate) fn metadata_cost( @@ -522,139 +526,65 @@ pub(crate) fn render_available_skills( budget: SkillMetadataBudget, include_skills_usage_instructions: bool, ) -> Option { - let mut entries = catalog - .entries - .iter() - .filter(|entry| entry.is_model_visible()) - .collect::>(); - policy.order_entries(&mut entries); - if entries.is_empty() { - return None; - } - - let absolute = render_catalog( - entries - .iter() - .map(|entry| SkillLine::new(entry, policy)) - .collect(), - budget, - Vec::new(), - SkillPromptKind::Unaliased, - policy, - ); - let selected = if let Some(aliased) = - build_aliased_catalog(&entries, policy, budget, include_skills_usage_instructions) - && aliased_render_is_better( - &aliased, - &absolute, - budget, - include_skills_usage_instructions, - ) { - aliased - } else { - absolute - }; - - Some(AvailableSkillsRender { - prompt_kind: selected.prompt_kind, - skill_root_lines: selected.skill_root_lines, - skill_lines: selected.skill_lines, - preserve_empty_fragment: policy == SkillCatalogRenderPolicy::CoreCompatible, - report: selected.report, - }) + PreparedSkillCatalog::new(catalog, policy).render(budget, include_skills_usage_instructions) } -pub(crate) fn render_combined_available_skills( - executor_catalog: &SkillCatalog, - cloud_catalog: &SkillCatalog, - host_catalog: &SkillCatalog, +pub(crate) fn render_prepared_skill_catalogs( + executor: &PreparedSkillCatalog<'_>, + cloud: &PreparedSkillCatalog<'_>, + host: &PreparedSkillCatalog<'_>, budget: SkillMetadataBudget, include_skills_usage_instructions: bool, ) -> RenderedSkillCatalogs { - let mut executor_entries = executor_catalog - .entries - .iter() - .filter(|entry| entry.is_model_visible()) - .collect::>(); - let mut cloud_entries = cloud_catalog - .entries - .iter() - .filter(|entry| entry.is_model_visible()) - .collect::>(); - let mut host_entries = host_catalog - .entries - .iter() - .filter(|entry| entry.is_model_visible()) - .collect::>(); - SkillCatalogRenderPolicy::ExtensionCompatible.order_entries(&mut executor_entries); - SkillCatalogRenderPolicy::ExtensionCompatible.order_entries(&mut cloud_entries); - SkillCatalogRenderPolicy::CoreCompatible.order_entries(&mut host_entries); let nonempty_catalog_count = [ - !executor_entries.is_empty(), - !cloud_entries.is_empty(), - !host_entries.is_empty(), + !executor.entries.is_empty(), + !cloud.entries.is_empty(), + !host.entries.is_empty(), ] .into_iter() .filter(|nonempty| *nonempty) .count(); if nonempty_catalog_count <= 1 { return RenderedSkillCatalogs { - executor: render_available_skills( - executor_catalog, - SkillCatalogRenderPolicy::ExtensionCompatible, - budget, - include_skills_usage_instructions, - ), - cloud: render_available_skills( - cloud_catalog, - SkillCatalogRenderPolicy::ExtensionCompatible, - budget, - include_skills_usage_instructions, - ), - host: render_available_skills( - host_catalog, - SkillCatalogRenderPolicy::CoreCompatible, - budget, - include_skills_usage_instructions, - ), + executor: executor.render(budget, include_skills_usage_instructions), + cloud: cloud.render(budget, include_skills_usage_instructions), + host: host.render(budget, include_skills_usage_instructions), }; } - let extension_policy = SkillCatalogRenderPolicy::ExtensionCompatible; - let host_policy = SkillCatalogRenderPolicy::CoreCompatible; let absolute = render_combined_lines( - CatalogLines::unaliased(&executor_entries, extension_policy), - CatalogLines::unaliased(&cloud_entries, extension_policy), - CatalogLines::unaliased(&host_entries, host_policy), + executor.unaliased(), + cloud.unaliased(), + host.unaliased(), budget, ); let mut selected = absolute; let host_only_aliases = build_aliased_combined_catalog( - CatalogLines::unaliased(&executor_entries, extension_policy), - CatalogLines::unaliased(&cloud_entries, extension_policy), - CatalogLines::aliased(&host_entries, host_policy), + executor.unaliased(), + cloud.unaliased(), + host.aliased(), budget, include_skills_usage_instructions, ); let executor_only_aliases = build_aliased_combined_catalog( - CatalogLines::aliased(&executor_entries, extension_policy), - CatalogLines::unaliased(&cloud_entries, extension_policy), - CatalogLines::unaliased(&host_entries, host_policy), + executor.aliased(), + cloud.unaliased(), + host.unaliased(), budget, include_skills_usage_instructions, ); let cloud_only_aliases = build_aliased_combined_catalog( - CatalogLines::unaliased(&executor_entries, extension_policy), - CatalogLines::aliased(&cloud_entries, extension_policy), - CatalogLines::unaliased(&host_entries, host_policy), + executor.unaliased(), + cloud.aliased(), + host.unaliased(), budget, include_skills_usage_instructions, ); let all_source_aliases = build_aliased_combined_catalog( - CatalogLines::aliased(&executor_entries, extension_policy), - CatalogLines::aliased(&cloud_entries, extension_policy), - CatalogLines::aliased(&host_entries, host_policy), + executor.aliased(), + cloud.aliased(), + host.aliased(), budget, include_skills_usage_instructions, ); @@ -709,8 +639,12 @@ impl<'a> CatalogLines<'a> { } } - fn aliased(entries: &[&'a SkillCatalogEntry], policy: SkillCatalogRenderPolicy) -> Self { - let Some(plan) = build_alias_plan(entries) else { + fn aliased( + entries: &[&'a SkillCatalogEntry], + policy: SkillCatalogRenderPolicy, + alias_plan: Option<&AliasPlan>, + ) -> Self { + let Some(plan) = alias_plan.filter(|_| !entries.is_empty()) else { return Self::unaliased(entries, policy); }; @@ -725,7 +659,7 @@ impl<'a> CatalogLines<'a> { SkillLine::with_locator( entry, policy, - render_skill_locator_with_aliases(entry, &plan), + render_skill_locator_with_aliases(entry, plan), ) }) .collect(), @@ -1014,12 +948,11 @@ fn available_skills_fragment( } fn build_aliased_catalog( - entries: &[&SkillCatalogEntry], + catalog: CatalogLines<'_>, policy: SkillCatalogRenderPolicy, budget: SkillMetadataBudget, include_skills_usage_instructions: bool, ) -> Option { - let catalog = CatalogLines::aliased(entries, policy); if catalog.root_lines.is_empty() { return None; } diff --git a/codex-rs/ext/skills/src/render_dedup.rs b/codex-rs/ext/skills/src/render_dedup.rs new file mode 100644 index 000000000000..93902f34352e --- /dev/null +++ b/codex-rs/ext/skills/src/render_dedup.rs @@ -0,0 +1,103 @@ +//! Prefers cloud skills before budgeting while retaining aliases from the full visible inventory. + +use std::collections::HashSet; + +use super::AvailableSkillsRender; +use super::CatalogLines; +use super::SkillCatalogRenderPolicy; +use super::SkillMetadataBudget; +use super::aliased_render_is_better; +use super::build_alias_plan; +use super::build_aliased_catalog; +use super::render_catalog; +use crate::aliases::AliasPlan; +use crate::catalog::SkillCatalog; +use crate::catalog::SkillCatalogEntry; +use crate::catalog_prompt::SkillPromptKind; + +pub(crate) struct PreparedSkillCatalog<'a> { + pub(super) entries: Vec<&'a SkillCatalogEntry>, + alias_plan: Option, + policy: SkillCatalogRenderPolicy, +} + +impl<'a> PreparedSkillCatalog<'a> { + pub(crate) fn new(catalog: &'a SkillCatalog, policy: SkillCatalogRenderPolicy) -> Self { + let mut entries = catalog + .entries + .iter() + .filter(|entry| entry.is_model_visible()) + .collect::>(); + policy.order_entries(&mut entries); + let alias_plan = build_alias_plan(&entries); + Self { + entries, + alias_plan, + policy, + } + } + + pub(crate) fn prefer_cloud_skills(&mut self, cloud: &SkillCatalog) { + let cloud_names: HashSet<_> = cloud + .entries + .iter() + .filter(|entry| entry.is_model_visible()) + .map(|entry| entry.name.as_str()) + .filter(|name| { + name.split_once(':') + .is_some_and(|(plugin, skill)| !plugin.is_empty() && !skill.is_empty()) + }) + .collect(); + + self.entries + .retain(|entry| !cloud_names.contains(entry.name.as_str())); + } + + pub(super) fn unaliased(&self) -> CatalogLines<'a> { + CatalogLines::unaliased(&self.entries, self.policy) + } + + pub(super) fn aliased(&self) -> CatalogLines<'a> { + CatalogLines::aliased(&self.entries, self.policy, self.alias_plan.as_ref()) + } + + pub(crate) fn render( + &self, + budget: SkillMetadataBudget, + include_skills_usage_instructions: bool, + ) -> Option { + if self.entries.is_empty() { + return None; + } + let absolute = render_catalog( + self.unaliased().skills, + budget, + Vec::new(), + SkillPromptKind::Unaliased, + self.policy, + ); + let selected = if let Some(aliased) = build_aliased_catalog( + self.aliased(), + self.policy, + budget, + include_skills_usage_instructions, + ) && aliased_render_is_better( + &aliased, + &absolute, + budget, + include_skills_usage_instructions, + ) { + aliased + } else { + absolute + }; + + Some(AvailableSkillsRender { + prompt_kind: selected.prompt_kind, + skill_root_lines: selected.skill_root_lines, + skill_lines: selected.skill_lines, + preserve_empty_fragment: self.policy == SkillCatalogRenderPolicy::CoreCompatible, + report: selected.report, + }) + } +} diff --git a/codex-rs/ext/skills/src/render_tests.rs b/codex-rs/ext/skills/src/render_tests.rs index 823702a49954..58aa7ae0147f 100644 --- a/codex-rs/ext/skills/src/render_tests.rs +++ b/codex-rs/ext/skills/src/render_tests.rs @@ -20,6 +20,25 @@ use crate::catalog_prompt::render_available_skills_body; use crate::loader::HostSkillRoot; use crate::loader::load_and_merge_host_skill_roots; +fn render_combined_available_skills( + executor_catalog: &SkillCatalog, + cloud_catalog: &SkillCatalog, + host_catalog: &SkillCatalog, + budget: SkillMetadataBudget, + include_skills_usage_instructions: bool, +) -> RenderedSkillCatalogs { + render_prepared_skill_catalogs( + &PreparedSkillCatalog::new( + executor_catalog, + SkillCatalogRenderPolicy::ExtensionCompatible, + ), + &PreparedSkillCatalog::new(cloud_catalog, SkillCatalogRenderPolicy::ExtensionCompatible), + &PreparedSkillCatalog::new(host_catalog, SkillCatalogRenderPolicy::CoreCompatible), + budget, + include_skills_usage_instructions, + ) +} + #[test] fn skill_prompt_contents_are_bounded_at_utf8_boundaries() { let contents = format!("{}é", "a".repeat(MAX_SKILL_PROMPT_BYTES - 1)); @@ -30,6 +49,61 @@ fn skill_prompt_contents_are_bounded_at_utf8_boundaries() { assert_eq!(truncated, true); } +#[test] +fn prebudget_dedup_can_choose_full_locators_over_preserved_aliases() { + let hidden_root = format!("skill://executor/{}", "long-package-root/".repeat(40)); + let mut hidden = entry( + "demo:hidden", + "Hidden duplicate.", + /*short_description*/ None, + ) + .with_alias_root(hidden_root.clone()); + hidden.authority.kind = SkillSourceKind::Executor; + hidden.id = SkillPackageId(format!("{hidden_root}/hidden")); + let mut visible = entry( + "other:visible", + "Keep this description.", + /*short_description*/ None, + ) + .with_alias_root("skill://short"); + visible.authority.kind = SkillSourceKind::Executor; + visible.id = SkillPackageId("skill://short/visible".to_string()); + let executor = SkillCatalog { + entries: vec![hidden.clone(), visible], + warnings: Vec::new(), + }; + hidden.authority.kind = SkillSourceKind::Cloud; + let cloud = SkillCatalog { + entries: vec![hidden], + warnings: Vec::new(), + }; + let mut prepared = + PreparedSkillCatalog::new(&executor, SkillCatalogRenderPolicy::ExtensionCompatible); + prepared.prefer_cloud_skills(&cloud); + let rendered = prepared + .render( + SkillMetadataBudget::Characters(300), + /*include_skills_usage_instructions*/ false, + ) + .expect("remaining executor skill should render"); + assert!(rendered.skill_root_lines.is_empty()); + assert_eq!( + rendered.skill_lines, + vec![ + "- other:visible: Keep this description. (executor package: skill://short/visible)" + .to_string(), + ] + ); + assert_eq!( + rendered.report, + SkillRenderReport { + total_count: 1, + included_count: 1, + ..Default::default() + } + ); +} + fn entry(name: &str, description: &str, short_description: Option<&str>) -> SkillCatalogEntry { entry_with_path( name, diff --git a/codex-rs/ext/skills/src/world_state_catalogs.rs b/codex-rs/ext/skills/src/world_state_catalogs.rs index fc3feccff84c..d0b76d38faf5 100644 --- a/codex-rs/ext/skills/src/world_state_catalogs.rs +++ b/codex-rs/ext/skills/src/world_state_catalogs.rs @@ -15,11 +15,12 @@ use crate::catalog::SkillCatalog; use crate::provider::SkillListQuery; use crate::provider::attribute_executor_plugins; use crate::render::AvailableSkillsRender; +use crate::render::PreparedSkillCatalog; use crate::render::RenderedSkillCatalogs; use crate::render::SkillCatalogRenderPolicy; use crate::render::SkillMetadataBudget; use crate::render::render_available_skills; -use crate::render::render_combined_available_skills; +use crate::render::render_prepared_skill_catalogs; use crate::render::skill_metadata_budget; use crate::render_observability::CatalogSurface; use crate::render_observability::record_catalog_render; @@ -265,6 +266,11 @@ impl<'a> CatalogContext<'a> { ) -> [RenderedCatalogContribution; 3] { let total_limit = self.metadata_budget.limit(); let default_cloud_limit = total_limit - total_limit / FILESYSTEM_BUDGET_DIVISOR; + let mut executor = PreparedSkillCatalog::new( + &catalogs.executor.catalog, + SkillCatalogRenderPolicy::ExtensionCompatible, + ); + executor.prefer_cloud_skills(&catalogs.cloud.catalog); // Identify the full visible cloud inventory before budgeting, including entries // a previous cap omitted. Executor readiness and provider warnings are not inputs. let cloud_metadata = catalogs @@ -307,14 +313,17 @@ impl<'a> CatalogContext<'a> { }); allocation.cloud_limit = allocation.cloud_limit.min(default_cloud_limit); let (mut rendered, mut filesystem_budget) = - self.render_with_cloud_limit(&catalogs, allocation.cloud_limit); - - if let Some(cloud_limit) = - self.rebalance_to_retain_all_skills(&catalogs, &rendered, allocation.cloud_limit) - { + self.render_with_cloud_limit(&catalogs, &executor, allocation.cloud_limit); + + if let Some(cloud_limit) = self.rebalance_to_retain_all_skills( + &catalogs, + &executor, + &rendered, + allocation.cloud_limit, + ) { allocation.cloud_limit = cloud_limit; (rendered, filesystem_budget) = - self.render_with_cloud_limit(&catalogs, allocation.cloud_limit); + self.render_with_cloud_limit(&catalogs, &executor, allocation.cloud_limit); } [ @@ -341,6 +350,7 @@ impl<'a> CatalogContext<'a> { fn rebalance_to_retain_all_skills( &self, catalogs: &CatalogContributions, + executor: &PreparedSkillCatalog<'_>, rendered: &RenderedSkillCatalogs, current_cloud_limit: usize, ) -> Option { @@ -352,10 +362,16 @@ impl<'a> CatalogContext<'a> { return None; } - let shared = render_combined_available_skills( - &catalogs.executor.catalog, - &catalogs.cloud.catalog, - &catalogs.host.catalog, + let shared = render_prepared_skill_catalogs( + executor, + &PreparedSkillCatalog::new( + &catalogs.cloud.catalog, + SkillCatalogRenderPolicy::ExtensionCompatible, + ), + &PreparedSkillCatalog::new( + &catalogs.host.catalog, + SkillCatalogRenderPolicy::CoreCompatible, + ), self.metadata_budget, self.include_usage, ); @@ -378,6 +394,7 @@ impl<'a> CatalogContext<'a> { fn render_with_cloud_limit( &self, catalogs: &CatalogContributions, + executor: &PreparedSkillCatalog<'_>, cloud_limit: usize, ) -> (RenderedSkillCatalogs, SkillMetadataBudget) { if !self.config.include_instructions { @@ -398,10 +415,16 @@ impl<'a> CatalogContext<'a> { let filesystem_budget = self .metadata_budget .with_limit(self.metadata_budget.limit().saturating_sub(cloud_cost)); - let mut rendered = render_combined_available_skills( - &catalogs.executor.catalog, - &SkillCatalog::default(), - &catalogs.host.catalog, + let mut rendered = render_prepared_skill_catalogs( + executor, + &PreparedSkillCatalog::new( + &SkillCatalog::default(), + SkillCatalogRenderPolicy::ExtensionCompatible, + ), + &PreparedSkillCatalog::new( + &catalogs.host.catalog, + SkillCatalogRenderPolicy::CoreCompatible, + ), filesystem_budget, self.include_usage, ); diff --git a/codex-rs/ext/skills/tests/skills_extension.rs b/codex-rs/ext/skills/tests/skills_extension.rs index f980b7e4a0a4..5d7ddc6be07b 100644 --- a/codex-rs/ext/skills/tests/skills_extension.rs +++ b/codex-rs/ext/skills/tests/skills_extension.rs @@ -89,6 +89,9 @@ type TestResult = Result<(), Box>; #[path = "skills_extension/shadow_task_context_tests.rs"] mod shadow_task_context_tests; +#[path = "skills_extension/presentation_dedup_tests.rs"] +mod presentation_dedup_tests; + static NEXT_CODEX_HOME_ID: AtomicUsize = AtomicUsize::new(0); const SKILLS_INTRO_WITH_ABSOLUTE_PATHS: &str = "A skill is a set of instructions provided through a `SKILL.md` source. Below is the list of skills that can be used. Each entry includes a name, description, and source locator. `file` locators are on the host filesystem, `executor package` locators are owned by their execution environment, `cloud package` locators are opaque package identifiers, and `custom resource` locators use their provider's access mechanism."; const DEMO_SKILL_CONTENTS: &str = diff --git a/codex-rs/ext/skills/tests/skills_extension/presentation_dedup_tests.rs b/codex-rs/ext/skills/tests/skills_extension/presentation_dedup_tests.rs new file mode 100644 index 000000000000..ad078fba53d4 --- /dev/null +++ b/codex-rs/ext/skills/tests/skills_extension/presentation_dedup_tests.rs @@ -0,0 +1,239 @@ +//! Pre-budget deduplication preserves source packages and aliases across executor readiness changes. + +use super::*; +use codex_exec_server::EnvironmentManager; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn cloud_preference_preserves_aliases_reads_and_executor_fallback() -> TestResult { + for (cloud_enabled, all_overlap) in [(true, false), (false, false), (true, true)] { + let description = format!("{} UNIQUE_DESCRIPTION_END", "Skill guidance. ".repeat(60)); + let executor_roots = [ + format!("skill://executor-a/{}", "a".repeat(180)), + format!("skill://executor-b/{}", "b".repeat(180)), + ]; + let mut executor_entries = Vec::new(); + for (plugin, root) in ["demo", "other"].into_iter().zip(&executor_roots) { + for index in 0..3 { + let package = format!("{root}/s{index}"); + let mut entry = test_entry( + SkillSourceKind::Executor, + "skills", + &package, + &format!("{package}/SKILL.md"), + ); + entry.name = format!("{plugin}:s{index}"); + entry.description = description.clone(); + executor_entries.push(entry.with_alias_root(root)); + } + } + let mut cloud_entries = Vec::new(); + for plugin in if all_overlap { + vec!["demo", "other"] + } else { + vec!["demo"] + } { + for index in 0..3 { + let mut entry = test_entry( + SkillSourceKind::Cloud, + "codex_apps", + &format!("cloud/{plugin}/s{index}"), + &format!("cloud/{plugin}/s{index}/SKILL.md"), + ); + entry.name = format!("{plugin}:s{index}"); + cloud_entries.push(entry); + } + } + let provider = |entries| { + Arc::new(StaticSkillProvider { + catalog: SkillCatalog { + entries, + warnings: Vec::new(), + }, + read_requests: Arc::new(Mutex::new(Vec::new())), + list_calls: None, + fail_first_list: false, + }) + }; + let providers = SkillProviders::new() + .with_executor_provider(provider(executor_entries.clone())) + .with_cloud_provider(provider(cloud_entries)); + let mut builder = ExtensionRegistryBuilder::new(); + install_with_providers(&mut builder, providers, skills_extension_config); + let registry = builder.build(); + let session_store = ExtensionData::new("session"); + let thread_store = ExtensionData::new("thread"); + let config = TestConfig { + cloud_skill_enabled: cloud_enabled, + ..default_config() + }; + registry.thread_lifecycle_contributors()[0] + .on_thread_start(ThreadStartInput { + config: &config, + session_source: &SessionSource::Cli, + persistent_thread_state_available: true, + environments: &[], + mcp_resource_client: None, + extension_metrics: None, + session_store: &session_store, + thread_store: &thread_store, + }) + .await; + let roots = [SelectedCapabilityRoot { + id: "skills".to_string(), + location: CapabilityRootLocation::Environment { + environment_id: "local".to_string(), + path: PathUri::parse("file:///skills")?, + }, + }]; + let resolved_roots = EnvironmentManager::default_for_tests() + .resolve_selected_capability_roots(&roots, &Default::default()) + .await; + assert_eq!(resolved_roots.len(), 1); + let mut previous = serde_json::Map::new(); + let mut initial_cloud_body = None; + let model_info = ModelInfo { + context_window: Some(70_000), + ..catalog_model_info() + }; + for (stage, ready) in [ + ("before", false), + ("connected", true), + ("disconnected", false), + ] { + start_registered_turn(®istry, &session_store, &thread_store, stage).await; + let turn_store = ExtensionData::new(stage); + let sections = registry.context_contributors()[0] + .contribute_world_state(WorldStateContributionInput { + previous_world_state: Some(&previous), + model_info: &model_info, + thread_id: codex_protocol::ThreadId::new(), + turn_id: stage, + environments: &[], + ready_selected_capability_roots: if ready { &roots } else { &[] }, + executor_capability_discovery: None, + extension_metrics: None, + session_store: &session_store, + thread_store: &thread_store, + turn_store: &turn_store, + }) + .await; + let executor_body = world_state_section(§ions, "skills").snapshot()["body"] + .as_str() + .unwrap_or_default() + .to_string(); + let cloud_body = world_state_section(§ions, "cloud_skills").snapshot()["body"] + .as_str() + .unwrap_or_default() + .to_string(); + for index in 0..3 { + let listing = format!("- demo:s{index}:"); + assert_eq!(executor_body.contains(&listing), ready && !cloud_enabled); + assert_eq!(cloud_body.contains(&listing), cloud_enabled); + assert_eq!( + executor_body.contains(&format!("- other:s{index}:")), + ready && !all_overlap + ); + } + assert_eq!( + executor_body.matches("UNIQUE_DESCRIPTION_END").count(), + if ready && cloud_enabled && !all_overlap { + 3 + } else { + 0 + } + ); + if stage == "before" { + initial_cloud_body = Some(cloud_body.clone()); + } else { + assert_eq!(Some(&cloud_body), initial_cloud_body.as_ref()); + } + if !ready || all_overlap { + assert!(executor_body.is_empty()); + } else { + assert!(executor_body.contains("(executor package: e1/s0)")); + for (index, root) in executor_roots.iter().enumerate() { + assert!(executor_body.contains(&format!("- `e{index}` = `{root}`"))); + } + } + previous = sections + .iter() + .map(|section| (section.id().to_string(), section.snapshot().clone())) + .collect(); + if !ready { + continue; + } + turn_store.insert(resolved_roots.clone()); + let tools = registry.tool_contributors()[0].tools_for_step( + &session_store, + &thread_store, + &turn_store, + ); + for (name, arguments, expected_resource) in [ + ( + "list", + serde_json::json!({"authority": {"kind": "executor"}}), + None, + ), + ( + "read", + serde_json::json!({"package": "e0/s0"}), + Some(executor_entries[0].main_prompt.as_str()), + ), + ( + "read", + serde_json::json!({"package": "e1/s0"}), + Some(executor_entries[3].main_prompt.as_str()), + ), + ( + "read", + serde_json::json!({"package": executor_entries[0].id.0}), + Some(executor_entries[0].main_prompt.as_str()), + ), + ] { + let tool = tools + .iter() + .find(|tool| tool.tool_name().name == name) + .ok_or("missing tool")?; + let payload = ToolPayload::Function { + arguments: arguments.to_string(), + }; + let output = tool + .handle(ToolCall { + turn_id: stage.to_string(), + call_id: "call".to_string(), + tool_name: tool.tool_name(), + model: "test".to_string(), + codex_turn_metadata: None, + truncation_policy: TruncationPolicy::Bytes(20_000), + source: ToolCallSource::Direct, + conversation_history: ConversationHistory::default(), + turn_item_emitter: Arc::new(NoopTurnItemEmitter), + environments: Vec::new(), + payload: payload.clone(), + }) + .await?; + let response = output + .post_tool_use_response("call", &payload) + .ok_or("missing response")?; + if let Some(resource) = expected_resource { + assert_eq!(response["resource"], resource); + } else { + assert_eq!( + response["skills"] + .as_array() + .ok_or("missing skills")? + .iter() + .map(|skill| skill["package"].as_str()) + .collect::>(), + executor_entries + .iter() + .map(|entry| Some(entry.id.0.as_str())) + .collect::>() + ); + } + } + } + } + Ok(()) +} diff --git a/codex-rs/git-utils/Cargo.toml b/codex-rs/git-utils/Cargo.toml index a0bd7872854c..41f3147685fb 100644 --- a/codex-rs/git-utils/Cargo.toml +++ b/codex-rs/git-utils/Cargo.toml @@ -9,6 +9,7 @@ readme = "README.md" workspace = true [dependencies] +codex-utils-process = { workspace = true } anyhow = { workspace = true } chrono = { workspace = true } codex-file-system = { workspace = true } diff --git a/codex-rs/git-utils/src/git_process.rs b/codex-rs/git-utils/src/git_process.rs index 3ce0431434d0..80c3d1859921 100644 --- a/codex-rs/git-utils/src/git_process.rs +++ b/codex-rs/git-utils/src/git_process.rs @@ -41,16 +41,7 @@ fn spawn_git_command(command: &mut Command) -> Option<(Child, KillGitProcessTree .stderr(Stdio::piped()); #[cfg(windows)] - let (child, job) = match JobObject::create() - .and_then(|job| job.spawn_contained(command).map(|child| (child, job))) - { - Ok((child, job)) => (child, Some(job)), - Err(_) => { - // A failed contained spawn leaves CREATE_SUSPENDED on the command. - command.creation_flags(0); - (command.spawn().ok()?, None) - } - }; + let (child, job) = JobObject::spawn_background(command).ok()?; #[cfg(not(windows))] let child = command.spawn().ok()?; diff --git a/codex-rs/git-utils/src/operations.rs b/codex-rs/git-utils/src/operations.rs index ba3196da585c..f9b9e4b02664 100644 --- a/codex-rs/git-utils/src/operations.rs +++ b/codex-rs/git-utils/src/operations.rs @@ -2,7 +2,6 @@ use std::ffi::OsStr; use std::ffi::OsString; use std::path::Path; use std::path::PathBuf; -use std::process::Command; use codex_protocol::shell_environment::scrub_non_inheritable_env_vars; @@ -130,7 +129,7 @@ where args_vec.push(OsString::from(arg.as_ref())); } let command_string = build_command_string(&args_vec); - let mut command = Command::new("git"); + let mut command = codex_utils_process::background_command("git"); command.current_dir(dir); if let Some(envs) = env { for (key, value) in envs { diff --git a/codex-rs/hooks/Cargo.toml b/codex-rs/hooks/Cargo.toml index cb234830aa95..4ad0b1ce9730 100644 --- a/codex-rs/hooks/Cargo.toml +++ b/codex-rs/hooks/Cargo.toml @@ -13,6 +13,7 @@ path = "src/lib.rs" workspace = true [dependencies] +codex-utils-process = { workspace = true } anyhow = { workspace = true } async-channel = { workspace = true } chrono = { workspace = true, features = ["serde"] } diff --git a/codex-rs/hooks/src/engine/command_runner.rs b/codex-rs/hooks/src/engine/command_runner.rs index d721500c17ba..674f5bd2edab 100644 --- a/codex-rs/hooks/src/engine/command_runner.rs +++ b/codex-rs/hooks/src/engine/command_runner.rs @@ -3,6 +3,8 @@ use std::ffi::OsStr; use std::ffi::OsString; use std::future::Future; use std::io::ErrorKind; +#[cfg(windows)] +use std::os::windows::process::CommandExt; use std::path::Path; #[cfg(not(unix))] use std::process::Stdio; @@ -27,6 +29,9 @@ use tokio::task::JoinSet; use tokio::time::timeout; use tracing::Span; +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x08000000; + use super::CommandShell; use super::ConfiguredHandler; use super::ConfiguredHandlerKind; @@ -221,18 +226,9 @@ pub(crate) async fn run_command( command.current_dir(cwd); #[cfg(windows)] - let mut process_tree_job = JobObject::create().ok(); - #[cfg(windows)] - let child = match process_tree_job.as_ref() { - Some(job) => match job.spawn_contained(&mut command) { - Ok(child) => Ok(child), - Err(_) => { - process_tree_job = None; - command.creation_flags(0); - command.spawn() - } - }, - None => command.spawn(), + let (child, process_tree_job) = match JobObject::spawn_background(&mut command) { + Ok((child, job)) => (Ok(child), job), + Err(error) => (Err(error), None), }; #[cfg(not(windows))] let child = command.spawn(); @@ -353,6 +349,7 @@ impl Drop for ProcessTreeGuard { .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) + .creation_flags(CREATE_NO_WINDOW) .spawn(); } } @@ -414,6 +411,8 @@ fn build_command( #[cfg(unix)] command.process_mode(codex_utils_pty::ProcessMode::NewSession); + #[cfg(windows)] + command.creation_flags(CREATE_NO_WINDOW); #[cfg(not(unix))] command .env_clear() diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index 5a033f9c64da..2049ac6c95e4 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -327,7 +327,7 @@ pub(crate) fn command_from_argv( if program.is_empty() { return None; } - let mut command = Command::new(program); + let mut command = Command::from(codex_utils_process::background_command(program)); command.args(args); command.env_clear(); command.envs(environment); diff --git a/codex-rs/login/Cargo.toml b/codex-rs/login/Cargo.toml index 2971dec6c1a8..8772f46b5867 100644 --- a/codex-rs/login/Cargo.toml +++ b/codex-rs/login/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true workspace = true [dependencies] +codex-utils-process = { workspace = true } base64 = { workspace = true } chrono = { workspace = true, features = ["serde"] } codex-agent-identity = { workspace = true } diff --git a/codex-rs/login/src/auth/external_bearer.rs b/codex-rs/login/src/auth/external_bearer.rs index 9f3fb64871e8..b8ad0d0672c6 100644 --- a/codex-rs/login/src/auth/external_bearer.rs +++ b/codex-rs/login/src/auth/external_bearer.rs @@ -102,7 +102,7 @@ struct CachedExternalBearerToken { async fn run_provider_auth_command(config: &ModelProviderAuthInfo) -> io::Result { let program = resolve_provider_auth_program(&config.command, &config.cwd)?; - let mut command = Command::new(&program); + let mut command = Command::from(codex_utils_process::background_command(&program)); command .args(config.args.iter().map(Deref::deref)) .current_dir(config.cwd.as_path()) diff --git a/codex-rs/model-provider/Cargo.toml b/codex-rs/model-provider/Cargo.toml index ecdcaa4b3e63..26c67921cb59 100644 --- a/codex-rs/model-provider/Cargo.toml +++ b/codex-rs/model-provider/Cargo.toml @@ -13,6 +13,7 @@ path = "src/lib.rs" workspace = true [dependencies] +codex-utils-process = { workspace = true } chrono = { workspace = true } codex-api = { workspace = true } codex-agent-identity = { workspace = true } diff --git a/codex-rs/model-provider/src/amazon_bedrock/auth_refresh.rs b/codex-rs/model-provider/src/amazon_bedrock/auth_refresh.rs index d15b5673dde2..a193243b232e 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/auth_refresh.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/auth_refresh.rs @@ -43,10 +43,17 @@ impl AwsAuthRecovery { return Ok(()); } - let mut command = Command::new(&self.config.command); + let interactive = std::io::stdin().is_terminal(); + let mut command = if interactive { + Command::new(&self.config.command) + } else { + Command::from(codex_utils_process::background_command( + &self.config.command, + )) + }; command .args(self.config.args.iter().map(Deref::deref)) - .stdin(if std::io::stdin().is_terminal() { + .stdin(if interactive { Stdio::inherit() } else { Stdio::null() diff --git a/codex-rs/model-provider/src/amazon_bedrock/credential_export.rs b/codex-rs/model-provider/src/amazon_bedrock/credential_export.rs index 7cabae337637..d6db7d93ed31 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/credential_export.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/credential_export.rs @@ -171,7 +171,7 @@ impl AwsCredentialExport { )); } - let mut command = Command::new(program); + let mut command = Command::from(codex_utils_process::background_command(program)); command .args(self.config.args.iter().map(Deref::deref)) .stdin(Stdio::null()) diff --git a/codex-rs/model-provider/src/models_endpoint.rs b/codex-rs/model-provider/src/models_endpoint.rs index 0fc8a0c8c303..6986451a8c21 100644 --- a/codex-rs/model-provider/src/models_endpoint.rs +++ b/codex-rs/model-provider/src/models_endpoint.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::fmt; use std::future::Future; use std::pin::Pin; @@ -181,6 +182,17 @@ impl OpenAiModelsEndpoint { }) .await .map_err(|_| CodexErr::RequestTimeout)??; + if self.provider_info.model_catalog_url.is_some() { + let mut slugs = HashSet::new(); + if models + .iter() + .any(|model| model.slug.trim().is_empty() || !slugs.insert(&model.slug)) + { + return Err(CodexErr::InvalidRequest( + "model catalog must contain unique, non-empty model slugs".to_string(), + )); + } + } Ok(ModelsEndpointResponse { models, etag, @@ -397,6 +409,7 @@ mod tests { use codex_models_manager::manager::ModelsManager; use codex_models_manager::manager::OpenAiModelsManager; use codex_models_manager::manager::RefreshStrategy; + use codex_models_manager::model_info::model_info_from_slug; use codex_protocol::auth::AuthMode; use codex_protocol::config_types::ModelProviderAuthInfo; use codex_protocol::error::CodexErrorDetails; @@ -701,7 +714,7 @@ mod tests { .unwrap(); let model = codex_protocol::openai_models::ModelInfo { used_fallback_model_metadata: false, - ..codex_models_manager::model_info::model_info_from_slug("command-auth-model") + ..model_info_from_slug("command-auth-model") }; Mock::given(method("GET")) .and(path("/models")) @@ -784,8 +797,7 @@ mod tests { CodexAuth::from_api_key("test-key"), ] { let server = MockServer::start().await; - let mut model = - codex_models_manager::model_info::model_info_from_slug("provider-model"); + let mut model = model_info_from_slug("provider-model"); model.visibility = ModelVisibility::List; model.supported_in_api = true; model.used_fallback_model_metadata = false; @@ -834,7 +846,8 @@ mod tests { home.path().to_path_buf(), endpoint, Some(auth_manager.clone()), - ); + ) + .with_provider_catalog(); // SIWC does not require the API-key rollout flag. manager.set_api_key_model_discovery_enabled( auth_manager.auth_mode() == Some(codex_protocol::auth::AuthMode::ApiKey), @@ -853,6 +866,41 @@ mod tests { } } + #[tokio::test] + async fn explicit_catalog_requires_unique_nonempty_slugs() { + for slugs in [vec![" \t"], vec!["private-model", "private-model"]] { + let server = MockServer::start().await; + let catalog = ModelsResponse { + models: slugs.into_iter().map(model_info_from_slug).collect(), + }; + Mock::given(method("GET")) + .and(path("/codex/models")) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(catalog)) + .expect(1) + .mount(&server) + .await; + let endpoint = OpenAiModelsEndpoint::new( + ModelProviderInfo { + model_catalog_url: Some(format!("{}/codex/models", server.uri()).into()), + ..ModelProviderInfo::default() + }, + /*auth_manager*/ None, + /*gateway_auth_manager*/ None, + ); + let error = endpoint + .list_models( + "1.2.3", + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + ) + .await + .expect_err("ambiguous model identity must be rejected"); + assert_eq!( + error.to_string(), + "model catalog must contain unique, non-empty model slugs" + ); + } + } + #[tokio::test] async fn explicit_catalog_rejects_oversized_response() { let server = MockServer::start().await; diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index fa30479f7fa5..b783184cd14a 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -399,6 +399,12 @@ struct ConfiguredModelProvider { gateway_auth_manager: Option, String>>, } +enum ModelsCacheConfig { + Disk { codex_home: PathBuf }, + Disabled, + Custom(Arc), +} + impl ConfiguredModelProvider { fn new( info: ModelProviderInfo, @@ -411,6 +417,40 @@ impl ConfiguredModelProvider { gateway_auth_manager, } } + + fn create_models_manager( + &self, + config_model_catalog: Option, + cache: ModelsCacheConfig, + ) -> SharedModelsManager { + if let Some(model_catalog) = config_model_catalog { + return Arc::new(StaticModelsManager::new( + self.auth_manager.clone(), + model_catalog, + )); + } + let endpoint = Arc::new(OpenAiModelsEndpoint::new( + self.info.clone(), + self.auth_manager.clone(), + self.gateway_auth_manager.clone(), + )); + let auth_manager = self.auth_manager.clone(); + let manager = match cache { + ModelsCacheConfig::Disk { codex_home } => { + OpenAiModelsManager::new(codex_home, endpoint, auth_manager) + } + ModelsCacheConfig::Disabled => { + OpenAiModelsManager::new_without_cache(endpoint, auth_manager) + } + ModelsCacheConfig::Custom(cache) => { + OpenAiModelsManager::new_with_cache(cache, endpoint, auth_manager) + } + }; + match &self.info.model_catalog_url { + Some(_) => Arc::new(manager.with_provider_catalog()), + None => Arc::new(manager), + } + } } impl ModelProvider for ConfiguredModelProvider { @@ -559,47 +599,14 @@ impl ModelProvider for ConfiguredModelProvider { codex_home: PathBuf, config_model_catalog: Option, ) -> SharedModelsManager { - match config_model_catalog { - Some(model_catalog) => Arc::new(StaticModelsManager::new( - self.auth_manager.clone(), - model_catalog, - )), - None => { - let endpoint = Arc::new(OpenAiModelsEndpoint::new( - self.info.clone(), - self.auth_manager.clone(), - self.gateway_auth_manager.clone(), - )); - Arc::new(OpenAiModelsManager::new( - codex_home, - endpoint, - self.auth_manager.clone(), - )) - } - } + self.create_models_manager(config_model_catalog, ModelsCacheConfig::Disk { codex_home }) } fn models_manager_without_cache( &self, config_model_catalog: Option, ) -> SharedModelsManager { - match config_model_catalog { - Some(model_catalog) => Arc::new(StaticModelsManager::new( - self.auth_manager.clone(), - model_catalog, - )), - None => { - let endpoint = Arc::new(OpenAiModelsEndpoint::new( - self.info.clone(), - self.auth_manager.clone(), - self.gateway_auth_manager.clone(), - )); - Arc::new(OpenAiModelsManager::new_without_cache( - endpoint, - self.auth_manager.clone(), - )) - } - } + self.create_models_manager(config_model_catalog, ModelsCacheConfig::Disabled) } fn models_manager_with_cache( @@ -607,24 +614,7 @@ impl ModelProvider for ConfiguredModelProvider { config_model_catalog: Option, cache: Arc, ) -> SharedModelsManager { - match config_model_catalog { - Some(model_catalog) => Arc::new(StaticModelsManager::new( - self.auth_manager.clone(), - model_catalog, - )), - None => { - let endpoint = Arc::new(OpenAiModelsEndpoint::new( - self.info.clone(), - self.auth_manager.clone(), - self.gateway_auth_manager.clone(), - )); - Arc::new(OpenAiModelsManager::new_with_cache( - cache, - endpoint, - self.auth_manager.clone(), - )) - } - } + self.create_models_manager(config_model_catalog, ModelsCacheConfig::Custom(cache)) } } diff --git a/codex-rs/models-manager/src/api_key_discovery_tests.rs b/codex-rs/models-manager/src/api_key_discovery_tests.rs index 6cda6088f4a8..e818a0c6efc4 100644 --- a/codex-rs/models-manager/src/api_key_discovery_tests.rs +++ b/codex-rs/models-manager/src/api_key_discovery_tests.rs @@ -12,11 +12,8 @@ async fn api_key_discovery_disabled_preserves_command_auth_discovery_and_merging )]; let endpoint = Arc::new(TestModelsEndpoint { has_command_auth: true, - uses_codex_backend: false, - responses: Mutex::new(vec![models.clone()].into()), - etag: None, - fetch_count: AtomicUsize::new(0), - observed_proxy_policy: Mutex::new(None), + responses: Mutex::new(vec![Ok(models.clone())].into()), + ..TestModelsEndpoint::default() }); let manager = OpenAiModelsManager::new_without_cache( endpoint.clone(), diff --git a/codex-rs/models-manager/src/cache.rs b/codex-rs/models-manager/src/cache.rs index 14d08977f440..7a0890760ade 100644 --- a/codex-rs/models-manager/src/cache.rs +++ b/codex-rs/models-manager/src/cache.rs @@ -64,6 +64,9 @@ pub type ModelsCacheFuture<'a, T> = Pin + Send + 'a>> #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ModelsCacheEntry { /// Time when the catalog was fetched or last revalidated with the models endpoint. + /// + /// `DateTime::::MIN_UTC` marks an invalidated catalog, both in memory and in storage. + /// The manager rejects it even if the backend uses native TTL. pub fetched_at: DateTime, /// Endpoint ETag used to revalidate the catalog without downloading an unchanged payload. #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/codex-rs/models-manager/src/manager.rs b/codex-rs/models-manager/src/manager.rs index 7b4d613be409..af734443c07a 100644 --- a/codex-rs/models-manager/src/manager.rs +++ b/codex-rs/models-manager/src/manager.rs @@ -4,6 +4,7 @@ use crate::cache::ModelsCacheEntry; use crate::collaboration_mode_presets::builtin_collaboration_mode_presets; use crate::config::ModelsManagerConfig; use crate::model_info; +use chrono::DateTime; use chrono::Utc; use codex_http_client::HttpClientFactory; use codex_login::AuthManager; @@ -22,6 +23,8 @@ use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Duration; +use tokio::sync::Mutex; +use tokio::sync::MutexGuard; use tokio::sync::RwLock; use tokio::sync::TryLockError; use tracing::Instrument as _; @@ -145,7 +148,7 @@ pub trait ModelsManager: fmt::Debug + Send + Sync { ) -> ModelsManagerFuture<'_, ModelsResponse>; /// Best-effort refresh when the in-memory catalog belongs to different credentials. - /// Static catalogs need no refresh. Failures leave the existing cache/default fallback. + /// Static catalogs need no refresh. Failures follow the catalog's fallback policy. fn refresh_after_auth_change( &self, _http_client_factory: HttpClientFactory, @@ -255,12 +258,37 @@ pub type ModelsManagerFuture<'a, T> = Pin + Send + 'a /// Shared model manager handle used across runtime services. pub type SharedModelsManager = Arc; +/// Only explicit catalogs serialize refresh and suppress bundled fallback. Auth and discovery +/// settings are still evaluated when models are requested. +#[derive(Debug)] +enum CatalogSource { + Default, + ExplicitProvider(Mutex<()>), +} + +impl CatalogSource { + fn fallback_models(&self) -> Option> { + match self { + Self::Default => Some(load_remote_models_from_file().unwrap_or_default()), + Self::ExplicitProvider(_) => None, + } + } + + async fn refresh_lock(&self) -> Option> { + match self { + Self::Default => None, + Self::ExplicitProvider(lock) => Some(lock.lock().await), + } + } +} + /// OpenAI-compatible model manager backed by bundled models, cache, and `/models`. #[derive(Debug)] pub struct OpenAiModelsManager { remote_models: RwLock, cache: Option>, endpoint_client: SharedModelsEndpointClient, + catalog_source: CatalogSource, api_key_model_discovery_enabled: AtomicBool, auth_manager: Option>, } @@ -315,21 +343,29 @@ impl OpenAiModelsManager { endpoint_client: Arc, auth_manager: Option>, ) -> Self { - let remote_models = load_remote_models_from_file().unwrap_or_default(); Self { remote_models: RwLock::new(ModelsCacheEntry { fetched_at: Utc::now(), etag: None, client_version: Some(crate::client_version_to_whole()), identity: endpoint_client.identity(), - models: remote_models, + models: load_remote_models_from_file().unwrap_or_default(), }), cache, api_key_model_discovery_enabled: AtomicBool::new(false), endpoint_client, + catalog_source: CatalogSource::Default, auth_manager, } } + + /// Configure a newly constructed manager to use only the provider's explicit catalog. + /// Clear the bundled seed before sharing it; the existing auth identity must be retained. + pub fn with_provider_catalog(mut self) -> Self { + self.catalog_source = CatalogSource::ExplicitProvider(Mutex::new(())); + self.remote_models.get_mut().models.clear(); + self + } } impl StaticModelsManager { @@ -396,7 +432,7 @@ impl ModelsManager for OpenAiModelsManager { if entry.identity.is_some() && entry.identity == self.endpoint_client.identity() { entry.models.clone() } else { - load_remote_models_from_file().unwrap_or_default() + self.catalog_source.fallback_models().unwrap_or_default() } }) } @@ -407,7 +443,7 @@ impl ModelsManager for OpenAiModelsManager { if entry.identity.is_some() && entry.identity == self.endpoint_client.identity() { entry.models.clone() } else { - load_remote_models_from_file().unwrap_or_default() + self.catalog_source.fallback_models().unwrap_or_default() }, ) } @@ -416,6 +452,27 @@ impl ModelsManager for OpenAiModelsManager { self.auth_manager.as_deref() } + #[tracing::instrument(skip_all, fields(model))] + fn get_model_info<'a>( + &'a self, + model: &'a str, + config: &'a ModelsManagerConfig, + ) -> ModelsManagerFuture<'a, ModelInfo> { + Box::pin(async move { + let models = self.get_remote_models().await; + match &self.catalog_source { + CatalogSource::Default => { + construct_model_info_from_candidates(model, &models, config) + } + CatalogSource::ExplicitProvider(_) => construct_model_info( + model, + models.into_iter().find(|candidate| candidate.slug == model), + config, + ), + } + }) + } + fn list_collaboration_modes(&self) -> Vec { builtin_collaboration_mode_presets() } @@ -451,6 +508,7 @@ impl OpenAiModelsManager { } async fn refresh_if_new_etag(&self, etag: String, http_client_factory: HttpClientFactory) { + let refresh_guard = self.catalog_source.refresh_lock().await; let (identity, current_etag) = { let entry = self.remote_models.read().await; (entry.identity.clone(), entry.etag.clone()) @@ -468,6 +526,7 @@ impl OpenAiModelsManager { } return; } + drop(refresh_guard); if let Err(err) = self .refresh_available_models(RefreshStrategy::Online, &http_client_factory) .await @@ -482,6 +541,7 @@ impl OpenAiModelsManager { refresh_strategy: RefreshStrategy, http_client_factory: &HttpClientFactory, ) -> CoreResult<()> { + let _refresh_guard = self.catalog_source.refresh_lock().await; // API-key discovery must be enabled and supported before reusing a remote catalog. // Otherwise even a matching cache from an earlier run would bypass bundled-only behavior. // Command-auth providers retain their existing discovery behavior. @@ -521,14 +581,54 @@ impl OpenAiModelsManager { http_client_factory: &HttpClientFactory, ) -> CoreResult<()> { let client_version = crate::client_version_to_whole(); + let request_identity = self.endpoint_client.identity(); let ModelsEndpointResponse { models, etag, identity, - } = self + } = match self .endpoint_client .list_models(&client_version, http_client_factory.clone()) - .await?; + .await + { + Ok(response) => response, + Err(error) => { + if matches!(&self.catalog_source, CatalogSource::ExplicitProvider(_)) { + { + let mut current = self.remote_models.write().await; + if request_identity != self.endpoint_client.identity() { + return Err(error); + } + // Keep this failure in memory even if persistent invalidation fails. + current.fetched_at = DateTime::::MIN_UTC; + current.models.clear(); + current.etag = None; + current.identity.clone_from(&request_identity); + } + if let Some(identity) = request_identity + && let Some(cache) = self.cache.as_ref() + { + let invalidation = async { + if let Some(mut entry) = cache.load(&client_version).await? + && entry.identity.as_deref() == Some(&identity) + && entry.client_version.as_deref() == Some(&client_version) + { + // Preserve backend lookup keys, but force a cache miss after restart. + entry.fetched_at = DateTime::::MIN_UTC; + entry.etag = None; + entry.models.clear(); + cache.store(&entry).await?; + } + Ok::<_, crate::cache::ModelsCacheError>(()) + }; + if let Err(err) = invalidation.await { + error!("failed to invalidate models cache: {err}"); + } + } + } + return Err(error); + } + }; if Some(&identity) != self.endpoint_client.identity().as_ref() { return Ok(()); } @@ -574,7 +674,6 @@ impl OpenAiModelsManager { if entry.identity != self.endpoint_client.identity() { return false; } - // Visible ChatGPT and OpenAI API-key catalogs are authoritative. let remote_only = entry .models .iter() @@ -585,8 +684,7 @@ impl OpenAiModelsManager { .auth_mode() .is_some_and(AuthMode::has_chatgpt_account) })); - if !remote_only { - let mut models = load_remote_models_from_file().unwrap_or_default(); + if !remote_only && let Some(mut models) = self.catalog_source.fallback_models() { for model in entry.models { if let Some(index) = models .iter() @@ -615,6 +713,15 @@ impl OpenAiModelsManager { let Some(identity) = self.endpoint_client.identity() else { return false; }; + { + let current = self.remote_models.read().await; + // A failed fetch must not be replaced by stale cache data even if invalidation failed. + if current.fetched_at == DateTime::::MIN_UTC + && current.identity.as_ref() == Some(&identity) + { + return false; + } + } let cache_entry = match cache.load(&client_version).await { Ok(Some(cache_entry)) => cache_entry, Ok(None) => { @@ -626,6 +733,9 @@ impl OpenAiModelsManager { return false; } }; + if cache_entry.fetched_at == DateTime::::MIN_UTC { + return false; + } if cache_entry.client_version.as_deref() != Some(client_version.as_str()) { info!( expected_version = client_version, @@ -788,6 +898,14 @@ pub(crate) fn construct_model_info_from_candidates( // retry for namespaced slugs like `custom/gpt-5.3-codex`. let remote = find_model_by_longest_prefix(model, candidates) .or_else(|| find_model_by_namespaced_suffix(model, candidates)); + construct_model_info(model, remote, config) +} + +fn construct_model_info( + model: &str, + remote: Option, + config: &ModelsManagerConfig, +) -> ModelInfo { let model_info = if let Some(remote) = remote { ModelInfo { slug: model.to_string(), diff --git a/codex-rs/models-manager/src/manager_tests.rs b/codex-rs/models-manager/src/manager_tests.rs index 4ef297142abc..04336f5665f5 100644 --- a/codex-rs/models-manager/src/manager_tests.rs +++ b/codex-rs/models-manager/src/manager_tests.rs @@ -16,6 +16,7 @@ use codex_login::ExternalAuth; use codex_login::ExternalAuthRefreshContext; use codex_login::TokenData; use codex_protocol::auth::AuthMode; +use codex_protocol::error::CodexErr; use codex_protocol::openai_models::ModelAccessPrograms; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::turn_input::CyberAccessProgram; @@ -92,17 +93,17 @@ fn assert_models_contain(actual: &[ModelInfo], expected: &[ModelInfo]) { } } -#[derive(Debug)] +#[derive(Debug, Default)] struct TestModelsEndpoint { has_command_auth: bool, uses_codex_backend: bool, - responses: Mutex>>, + responses: Mutex>>>, etag: Option, fetch_count: AtomicUsize, observed_proxy_policy: Mutex>, } -#[derive(Debug)] +#[derive(Debug, Default)] struct TestModelsCache { entry: Mutex>, load_error: bool, @@ -162,6 +163,7 @@ impl ModelsCache for TestModelsCache { if self.store_error { return Err(ModelsCacheError::new("test store failure")); } + *self.entry.lock().unwrap() = Some(entry.clone()); self.stored_entries .lock() .expect("stored entries lock should not be poisoned") @@ -196,23 +198,24 @@ impl ModelsCache for TestModelsCache { impl TestModelsEndpoint { fn new(responses: Vec>) -> Arc { Arc::new(Self { - has_command_auth: false, uses_codex_backend: true, - responses: Mutex::new(responses.into()), - etag: None, - fetch_count: AtomicUsize::new(0), - observed_proxy_policy: Mutex::new(None), + responses: Mutex::new(responses.into_iter().map(Ok).collect()), + ..Self::default() }) } fn without_refresh(responses: Vec>) -> Arc { Arc::new(Self { - has_command_auth: false, - uses_codex_backend: false, + responses: Mutex::new(responses.into_iter().map(Ok).collect()), + ..Self::default() + }) + } + + fn with_command_auth(responses: Vec>>) -> Arc { + Arc::new(Self { + has_command_auth: true, responses: Mutex::new(responses.into()), - etag: None, - fetch_count: AtomicUsize::new(0), - observed_proxy_policy: Mutex::new(None), + ..Self::default() }) } @@ -234,7 +237,7 @@ impl TestModelsEndpoint { .lock() .expect("responses lock should not be poisoned") .pop_front() - .unwrap_or_default(); + .unwrap_or_else(|| Ok(Vec::new()))?; Ok(ModelsEndpointResponse { models, etag: self.etag.clone(), @@ -1051,11 +1054,8 @@ async fn refresh_available_models_keeps_merging_for_custom_api_auth() { let codex_home = tempdir().expect("temp dir"); let endpoint = Arc::new(TestModelsEndpoint { has_command_auth: true, - uses_codex_backend: false, - responses: Mutex::new(vec![remote_models.clone()].into()), - etag: None, - fetch_count: AtomicUsize::new(0), - observed_proxy_policy: Mutex::new(None), + responses: Mutex::new(vec![Ok(remote_models.clone())].into()), + ..TestModelsEndpoint::default() }); let manager = openai_manager_for_tests_with_auth( codex_home.path().to_path_buf(), @@ -1079,6 +1079,86 @@ async fn refresh_available_models_keeps_merging_for_custom_api_auth() { assert_eq!(endpoint.fetch_count(), 1, "expected a single model fetch"); } +#[tokio::test] +async fn authoritative_catalog_preserves_hidden_models_and_matches_exact_ids() { + let model = + remote_model_with_visibility("gpt-6-sol", "Provider Model", /*priority*/ 0, "hide"); + let endpoint = TestModelsEndpoint::with_command_auth(vec![Ok(vec![model.clone()])]); + let manager = OpenAiModelsManager::new_without_cache(endpoint, /*auth_manager*/ None) + .with_provider_catalog(); + assert_eq!( + manager + .raw_model_catalog(RefreshStrategy::Online, DEFAULT_HTTP_CLIENT_FACTORY) + .await + .models, + vec![model] + ); + for unknown in ["gpt-6-sol-other", "namespace/gpt-6-sol"] { + assert_eq!( + manager + .get_model_info(unknown, &ModelsManagerConfig::default()) + .await, + model_info::model_info_from_slug(unknown) + ); + } +} + +#[tokio::test] +async fn authoritative_catalog_failure_invalidates_cache_until_refresh_succeeds() { + // Like a native TTL backend, this cache may return an invalidated stored entry. + let cache = Arc::new(TestModelsCache::default()); + let models = vec![remote_model( + "provider-model", + "Provider Model", + /*priority*/ 0, + )]; + let endpoint = TestModelsEndpoint::with_command_auth(vec![ + Ok(models.clone()), + Err(CodexErr::RequestTimeout), + Ok(models.clone()), + ]); + let manager = OpenAiModelsManager::new_with_cache( + cache.clone(), + endpoint.clone(), + /*auth_manager*/ None, + ) + .with_provider_catalog(); + assert_eq!( + manager + .raw_model_catalog(RefreshStrategy::Online, DEFAULT_HTTP_CLIENT_FACTORY) + .await + .models, + models + ); + for strategy in [RefreshStrategy::Online, RefreshStrategy::Offline] { + assert_eq!( + manager + .raw_model_catalog(strategy, DEFAULT_HTTP_CLIENT_FACTORY) + .await, + ModelsResponse::default() + ); + } + let restarted = + OpenAiModelsManager::new_with_cache(cache, endpoint, /*auth_manager*/ None) + .with_provider_catalog(); + assert_eq!( + restarted + .raw_model_catalog(RefreshStrategy::Offline, DEFAULT_HTTP_CLIENT_FACTORY) + .await, + ModelsResponse::default() + ); + assert_eq!( + restarted + .raw_model_catalog( + RefreshStrategy::OnlineIfUncached, + DEFAULT_HTTP_CLIENT_FACTORY, + ) + .await + .models, + models + ); +} + #[tokio::test] async fn refresh_available_models_uses_cache_when_fresh() { let remote_models = vec![remote_model("cached", "Cached", /*priority*/ 5)]; @@ -1129,12 +1209,10 @@ async fn online_refresh_updates_access_programs_with_unchanged_etag() { }; let responses = vec![vec![granted_model.clone()], vec![revoked_model.clone()]]; let endpoint = Arc::new(TestModelsEndpoint { - has_command_auth: false, uses_codex_backend: true, - responses: Mutex::new(responses.into()), + responses: Mutex::new(responses.into_iter().map(Ok).collect()), etag: Some("stable-catalog-etag".to_string()), - fetch_count: AtomicUsize::new(0), - observed_proxy_policy: Mutex::new(None), + ..TestModelsEndpoint::default() }); let manager = openai_manager_for_tests(codex_home.path().to_path_buf(), endpoint.clone()); diff --git a/codex-rs/rmcp-client/src/stdio_server_launcher.rs b/codex-rs/rmcp-client/src/stdio_server_launcher.rs index 13fabc46d978..433f532a136c 100644 --- a/codex-rs/rmcp-client/src/stdio_server_launcher.rs +++ b/codex-rs/rmcp-client/src/stdio_server_launcher.rs @@ -214,11 +214,6 @@ impl StdioServerLauncher for LocalStdioServerLauncher { // Local private implementation. -#[cfg(windows)] -const CREATE_NO_WINDOW: u32 = 0x0800_0000; -#[cfg(windows)] -const CREATE_SUSPENDED: u32 = 0x0000_0004; - #[cfg(unix)] const PROCESS_GROUP_TERM_GRACE_PERIOD: Duration = Duration::from_secs(2); @@ -293,8 +288,6 @@ impl LocalStdioServerLauncher { // handles and needs a handle allowlist in the shared spawn backend. #[cfg(unix)] command.descriptor_policy(DescriptorPolicy::Explicit); - #[cfg(windows)] - command.creation_flags(CREATE_NO_WINDOW); command }; #[cfg(windows)] @@ -305,8 +298,6 @@ impl LocalStdioServerLauncher { let job = match codex_utils_pty::JobObject::create_without_breakaway() { Ok(job) => { command.prepare_suspended_spawn(&job); - // The helper replaces creation flags; retain suspension and suppress the console. - command.creation_flags(CREATE_SUSPENDED | CREATE_NO_WINDOW); Some(job) } Err(error) => { diff --git a/codex-rs/rollout/Cargo.toml b/codex-rs/rollout/Cargo.toml index 1bb4b54f1464..8981dd966269 100644 --- a/codex-rs/rollout/Cargo.toml +++ b/codex-rs/rollout/Cargo.toml @@ -13,6 +13,7 @@ path = "src/lib.rs" workspace = true [dependencies] +codex-utils-process = { workspace = true } anyhow = { workspace = true } chrono = { workspace = true, features = ["serde"] } codex-extension-items = { workspace = true } diff --git a/codex-rs/rollout/src/search.rs b/codex-rs/rollout/src/search.rs index 1f2a13200152..b9c2bc734376 100644 --- a/codex-rs/rollout/src/search.rs +++ b/codex-rs/rollout/src/search.rs @@ -71,7 +71,7 @@ async fn ripgrep_rollout_paths( return Ok(Some(HashSet::new())); } - let output = match Command::new(rg_command) + let output = match Command::from(codex_utils_process::background_command(rg_command)) .arg("-l") .arg("--fixed-strings") .arg("--ignore-case") diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 7923527fff36..37b5c788ad9f 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -541,6 +541,7 @@ pub(crate) struct App { loader_overrides: LoaderOverrides, cloud_config_bundle: CloudConfigBundleLoader, runtime_approval_policy_override: Option, + runtime_approvals_reviewer_override: Option, runtime_permission_profile_override: Option, /// In-flight remote selections; confirmed settings live in each task's server snapshot. pending_server_profiles: HashMap, @@ -672,7 +673,6 @@ struct RuntimePermissionProfileOverride { permission_profile: PermissionProfile, active_permission_profile: Option, network: Option, - approvals_reviewer: ApprovalsReviewer, turn_override: RuntimePermissionProfileTurnOverride, } @@ -709,7 +709,6 @@ impl RuntimePermissionProfileOverride { permission_profile: config.permissions.permission_profile().clone(), active_permission_profile: config.permissions.active_permission_profile(), network: config.permissions.network.clone(), - approvals_reviewer: config.approvals_reviewer, turn_override: RuntimePermissionProfileTurnOverride::LegacySandbox, } } @@ -725,7 +724,6 @@ impl RuntimePermissionProfileOverride { self.permission_profile == *config.permissions.permission_profile() && self.active_permission_profile == config.permissions.active_permission_profile() && self.network == config.permissions.network - && self.approvals_reviewer == config.approvals_reviewer } fn turn_permission_profile(&self) -> Option<&PermissionProfile> { diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index 7a7fa7539be0..89db4ec2dd4e 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -535,7 +535,10 @@ impl App { app_server, &mut resume_config, target_thread.cwd.as_path(), - Some(&target_thread), + crate::onboarding::DirectoryTrustOptions { + resumed_thread: Some(&target_thread), + ..Default::default() + }, /*startup_draft*/ None, ) .await @@ -549,14 +552,18 @@ impl App { loading::draw(tui)?; } let baseline_approval = resume_config.permissions.approval_policy.value(); + let baseline_reviewer = resume_config.approvals_reviewer; let baseline_permissions = RuntimePermissionProfileOverride::from_config(&resume_config); let resume_model_settings = match target_thread.status { codex_app_server_protocol::ThreadStatus::NotLoaded => { - self.apply_runtime_policy_overrides( + if let Err(error) = self.apply_runtime_policy_overrides( &mut resume_config, RuntimePolicyOverrideScope::ExplicitOnly, - ); + ) { + self.add_agents_overview_error(format!("{error:#}")); + return Ok(AppRunControl::Continue); + } if matches!(self.runtime_approval_policy_override, Some(RuntimeApprovalPolicyOverride::Explicit(policy)) if policy.to_core() != resume_config.permissions.approval_policy.value()) @@ -604,11 +611,12 @@ impl App { (blank, false) } else { match app_server - .resume_thread( + .resume_thread_with_permission_overrides( &local_settings, resume_config.clone(), root_thread_id, resume_model_settings, + self.resume_permission_overrides(&resume_config), ) .await { @@ -702,7 +710,11 @@ impl App { } } } - // Explicit choices carry across cold resumes and new sessions. + // Read-only views retain explicit choices but never another task's restored state. + let preserve_explicit_permissions = preserve_explicit_permissions || read_only; + self.runtime_approvals_reviewer_override = self + .runtime_approvals_reviewer_override + .filter(|_| preserve_explicit_permissions); self.runtime_approval_policy_override = self.runtime_approval_policy_override.filter(|policy| { preserve_explicit_permissions @@ -761,22 +773,27 @@ impl App { .set_workspace_roots(self.config.permissions.workspace_roots().to_vec()); } self.config = destination_config; - let approval = self.config.permissions.approval_policy.value(); - if self - .runtime_approval_policy_override - .is_none_or(|policy| policy.policy().to_core() != approval) - { - self.runtime_approval_policy_override = (approval != baseline_approval) - .then_some(RuntimeApprovalPolicyOverride::Restored(approval.into())); - } - if self - .runtime_permission_profile_override - .as_ref() - .is_none_or(|profile| !profile.matches_config(&self.config)) - { - self.runtime_permission_profile_override = (!baseline_permissions - .matches_config(&self.config)) - .then(|| RuntimePermissionProfileOverride::from_restored_config(&self.config)); + if !read_only { + let approval = self.config.permissions.approval_policy.value(); + if self + .runtime_approval_policy_override + .is_none_or(|policy| policy.policy().to_core() != approval) + { + self.runtime_approval_policy_override = (approval != baseline_approval) + .then_some(RuntimeApprovalPolicyOverride::Restored(approval.into())); + } + if self + .runtime_permission_profile_override + .as_ref() + .is_none_or(|profile| !profile.matches_config(&self.config)) + { + self.runtime_permission_profile_override = (!baseline_permissions + .matches_config(&self.config) + || baseline_reviewer != self.config.approvals_reviewer) + .then(|| { + RuntimePermissionProfileOverride::from_restored_config(&self.config) + }); + } } // A new session has no descendants. Scanning every loaded thread here // adds a serial round trip per agent before the composer can render. @@ -925,7 +942,7 @@ impl App { app_server, &mut config, &trust_cwd, - /*resumed_thread*/ None, + crate::onboarding::DirectoryTrustOptions::default(), startup_draft.as_deref_mut(), ) .await @@ -948,16 +965,14 @@ impl App { ); return None; } - // New sessions use the destination settings plus explicit user choices, not - // a permission snapshot inherited when attaching to another task. - self.apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::ExplicitOnly); let defaults_cwd = match app_server.thread_params_mode() { crate::app_server_session::ThreadParamsMode::Embedded => config.cwd.as_path(), crate::app_server_session::ThreadParamsMode::Remote => remote_cwd .as_deref() .or_else(|| app_server.remote_cwd_override()) .unwrap_or(Path::new(".")), - }; + } + .to_path_buf(); if let Some(draft) = startup_draft.as_deref_mut() { draft.apply_config(&config); } @@ -967,12 +982,20 @@ impl App { tui, crate::config_update::read_effective_config_if_supported( app_server.request_handle(), - defaults_cwd, + &defaults_cwd, ), ) .await { Ok(Some(defaults)) => { + crate::projectless::apply_defaults( + &mut config, + &self.harness_overrides, + app_server, + &self.environment_manager, + &defaults, + ); + let defaults = defaults.config; server_model_cleared = defaults.model.is_none(); let use_server_provider = matches!( app_server.thread_params_mode(), @@ -1010,6 +1033,13 @@ impl App { return None; } } + // New sessions inherit explicit choices, not permissions restored from another task. + if let Err(error) = self + .apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::ExplicitOnly) + { + self.add_agents_overview_error(format!("{error:#}")); + return None; + } apply_managed_new_thread_defaults( &mut config, app_server.managed_new_thread_defaults(), diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index 4abcfe41ed05..b5f4d8892466 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -1733,6 +1733,7 @@ async fn overview_selection_applies_user_permissions_only_to_unloaded_threads() ) .await? .session; + app.runtime_approvals_reviewer_override = Some(ApprovalsReviewer::AutoReview); app.harness_overrides.sandbox_mode = Some(codex_protocol::config_types::SandboxMode::ReadOnly); app.harness_overrides.approval_policy = Some(codex_protocol::protocol::AskForApproval::UnlessTrusted); @@ -1777,6 +1778,7 @@ async fn overview_selection_applies_user_permissions_only_to_unloaded_threads() Some(codex_protocol::config_types::SandboxMode::WorkspaceWrite); app.harness_overrides.approval_policy = Some(codex_protocol::protocol::AskForApproval::Never); + app.runtime_approvals_reviewer_override = Some(app.config.approvals_reviewer); app.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config(&app.config)); app.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Explicit( @@ -1785,6 +1787,11 @@ async fn overview_selection_applies_user_permissions_only_to_unloaded_threads() } app.select_agents_overview_thread(&mut tui, &mut app_server, thread_id) .await?; + assert_eq!( + app.runtime_approvals_reviewer_override, + (thread_id == thread_ids[2] || thread_id == thread_ids[3]) + .then_some(ApprovalsReviewer::User) + ); let observed = app_server .resume_thread( &app.local_settings, @@ -1815,6 +1822,8 @@ async fn overview_selection_applies_user_permissions_only_to_unloaded_threads() "allowed_approvals_reviewers = [\"auto_review\"]\n", )?; app.loader_overrides.system_requirements_path = Some(requirements.to_path_buf()); + let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None); + app.chat_widget.show_bottom_pane_view(Box::new(view)); app.select_agents_overview_thread(&mut tui, &mut app_server, thread_ids[4]) .await?; assert_eq!( @@ -1830,6 +1839,59 @@ async fn overview_selection_applies_user_permissions_only_to_unloaded_threads() codex_app_server_protocol::ThreadStatus::NotLoaded ), ); + let mut rendered = + crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_widget, /*width*/ 96); + let start = rendered.find("(set by ").expect("requirement source"); + let end = start + rendered[start..].find(')').expect("source end") + 1; + rendered.replace_range(start..end, "(set by )"); + insta::with_settings!({snapshot_path => "../snapshots"}, { + insta::assert_snapshot!("agents_overview_reviewer_conflict", rendered); + }); + // The picker must also abort when a session-only choice violates destination policy. + std::fs::write(&requirements, "allowed_approval_policies = [\"never\"]\n")?; + app.harness_overrides.approval_policy = Some(codex_protocol::protocol::AskForApproval::Never); + app.resume_target_session( + &mut tui, + &mut app_server, + SessionTarget { + path: None, + thread_id: thread_ids[4], + cwd: None, + history_mode: None, + }, + ) + .await?; + assert_eq!(app.primary_thread_id, Some(thread_ids[3])); + assert_eq!( + app_server + .thread_read(thread_ids[4], /*include_turns*/ false) + .await? + .status, + codex_app_server_protocol::ThreadStatus::NotLoaded, + ); + + app.loader_overrides.system_requirements_path = None; + app.runtime_permission_profile_override = None; + Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::UpdateApprovalsReviewer(ApprovalsReviewer::AutoReview), + )) + .await?; + app.select_agents_overview_thread(&mut tui, &mut app_server, thread_ids[4]) + .await?; + let resumed = app_server + .resume_thread( + &app.local_settings, + app.config.clone(), + thread_ids[4], + crate::app_server_session::ResumeModelSettings::PreserveExistingThread, + ) + .await?; + assert_eq!( + resumed.session.approvals_reviewer, + ApprovalsReviewer::AutoReview + ); app_server.shutdown().await?; Ok(()) } @@ -2432,6 +2494,18 @@ async fn command_center_attach_conflict_opens_read_only_and_retries() -> Result< .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID); let mut tui = crate::tui::test_support::make_test_tui()?; + app.config.approvals_reviewer = ApprovalsReviewer::AutoReview; + app.runtime_approvals_reviewer_override = Some(ApprovalsReviewer::AutoReview); + app.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Explicit( + AskForApproval::Never, + )); + app.runtime_permission_profile_override = + Some(RuntimePermissionProfileOverride::from_config(&app.config)); + let expected_overrides = ( + app.runtime_approvals_reviewer_override, + app.runtime_approval_policy_override, + app.runtime_permission_profile_override.clone(), + ); app.chat_widget.handle_key_event(KeyCode::Right.into()); let event = rx.try_recv()?; assert!( @@ -2496,6 +2570,14 @@ async fn command_center_attach_conflict_opens_read_only_and_retries() -> Result< // An explicit retry remains read-only while the other server owns the task. Box::pin(app.handle_key_event(&mut tui, &mut server, KeyCode::Char('r').into())).await; assert!(app.chat_widget.is_external_writer_view()); + assert_eq!( + ( + app.runtime_approvals_reviewer_override, + app.runtime_approval_policy_override, + app.runtime_permission_profile_override.clone() + ), + expected_overrides, + ); assert_eq!( app.chat_widget.composer_text_with_pending(), "Retained task draft" diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index d04b74576dfa..00d2794e3248 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -58,36 +58,43 @@ pub(super) fn has_explicit_resume_permission_override( config: &Config, overrides: &ConfigOverrides, ) -> bool { - overrides.approval_policy.is_some() - || overrides.approvals_reviewer.is_some() - || overrides.sandbox_mode.is_some() - || overrides.permission_profile.is_some() - || overrides.default_permissions.is_some() - || !overrides.additional_writable_roots.is_empty() - || overrides.workspace_roots.is_some() - || config.config_layer_stack.layers_high_to_low().any(|layer| { - matches!( - &layer.name, - ConfigLayerSource::SessionFlags - | ConfigLayerSource::User { - profile: Some(_), - .. - } - ) && [ - "approval_policy", - "approvals_reviewer", - "sandbox_mode", - "default_permissions", - "permissions", - "network", - "sandbox_workspace_write", - ] - .iter() - .any(|key| layer.config.get(*key).is_some()) - }) + // A directory choice is supported remotely even when permission overrides are not. + let selected = crate::resume_permissions::ResumePermissions::from_overrides( + config, + &ConfigOverrides { + cwd: None, + ..overrides.clone() + }, + ); + selected.approval_policy + || selected.approvals_reviewer + || selected.profile + || selected.workspace_roots } impl App { + pub(super) fn resume_permission_overrides( + &self, + config: &Config, + ) -> crate::resume_permissions::ResumePermissions { + let mut permission_overrides = crate::resume_permissions::ResumePermissions::from_overrides( + config, + &self.harness_overrides, + ); + permission_overrides.approvals_reviewer |= + self.runtime_approvals_reviewer_override.is_some(); + permission_overrides.approval_policy |= matches!( + self.runtime_approval_policy_override, + Some(RuntimeApprovalPolicyOverride::Explicit(_)) + ); + if let Some(profile) = self.runtime_permission_profile_override.as_ref() + && profile.turn_override == RuntimePermissionProfileTurnOverride::LegacySandbox + { + permission_overrides.profile = true; + } + permission_overrides + } + pub(super) async fn rebuild_config_for_cwd(&self, cwd: PathBuf) -> Result { let mut overrides = self.harness_overrides.clone(); overrides.cwd = Some(cwd.clone()); @@ -227,6 +234,8 @@ impl App { .selected_permission_profiles .insert(thread_id, profile_id.clone()); } + self.runtime_approvals_reviewer_override = + approvals_reviewer.or(self.runtime_approvals_reviewer_override); self.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config(&self.config)); self.sync_active_thread_permission_settings_to_cached_session() @@ -409,7 +418,7 @@ impl App { let mut config = self .rebuild_config_for_cwd(self.chat_widget.config_ref().cwd.to_path_buf()) .await?; - self.apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::All); + self.apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::All)?; self.local_settings = self.local_settings.reloaded(&config); self.refresh_server_version_overview_notice(CODEX_CLI_VERSION); // Other preferences have runtime caches and are adopted when the widget is replaced. @@ -485,7 +494,7 @@ impl App { &mut self, config: &mut Config, scope: RuntimePolicyOverrideScope, - ) { + ) -> Result<()> { if let Some(policy) = self.runtime_approval_policy_override && (scope == RuntimePolicyOverrideScope::All || matches!(policy, RuntimeApprovalPolicyOverride::Explicit(_))) @@ -494,27 +503,38 @@ impl App { .approval_policy .set(policy.policy().to_core()) { + if scope == RuntimePolicyOverrideScope::ExplicitOnly { + return Err(err).wrap_err("Failed to carry forward approval policy override"); + } tracing::warn!(%err, "failed to carry forward approval policy override"); self.chat_widget.add_error_message(format!( "Failed to carry forward approval policy override: {err}" )); } - if let Some(profile_override) = self.runtime_permission_profile_override.as_ref() - && (scope == RuntimePolicyOverrideScope::All - || profile_override.turn_override - == RuntimePermissionProfileTurnOverride::LegacySandbox) - { - match config + let profile_override = + self.runtime_permission_profile_override + .as_ref() + .filter(|profile| { + scope == RuntimePolicyOverrideScope::All + || profile.turn_override + == RuntimePermissionProfileTurnOverride::LegacySandbox + }); + if let Some(reviewer) = self.runtime_approvals_reviewer_override.or_else(|| { + profile_override + .filter(|profile| { + profile.turn_override == RuntimePermissionProfileTurnOverride::Preserve + }) + .map(|_| self.config.approvals_reviewer) + }) { + config .config_layer_stack .requirements() .approvals_reviewer - .can_set(&profile_override.approvals_reviewer) - { - Ok(()) => config.approvals_reviewer = profile_override.approvals_reviewer, - Err(error) => self.chat_widget.add_error_message(format!( - "Failed to carry forward approvals reviewer: {error}" - )), - } + .can_set(&reviewer) + .wrap_err("Failed to carry forward approvals reviewer")?; + config.approvals_reviewer = reviewer; + } + if let Some(profile_override) = profile_override { match config .permissions .set_permission_profile_from_session_snapshot( @@ -527,6 +547,10 @@ impl App { config.permissions.network = profile_override.network.clone(); } Err(err) => { + if scope == RuntimePolicyOverrideScope::ExplicitOnly { + return Err(err) + .wrap_err("Failed to carry forward permission profile override"); + } tracing::warn!(%err, "failed to carry forward permission profile override"); self.chat_widget.add_error_message(format!( "Failed to carry forward permission profile override: {err}" @@ -534,6 +558,7 @@ impl App { } } } + Ok(()) } pub(super) fn set_approvals_reviewer_in_app_and_widget(&mut self, reviewer: ApprovalsReviewer) { @@ -770,6 +795,7 @@ impl App { self.chat_widget.add_memories_enable_notice(); } if approvals_reviewer_override.is_some() { + self.runtime_approvals_reviewer_override = approvals_reviewer_override; self.set_approvals_reviewer_in_app_and_widget(self.config.approvals_reviewer); } if approval_policy_override.is_some() { @@ -798,6 +824,7 @@ impl App { .add_error_message(format!("Failed to enable Approve for me: {err}")); } if permission_profile_override.is_some() { + self.runtime_approvals_reviewer_override = Some(self.config.approvals_reviewer); self.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config(&self.config)); } @@ -1059,6 +1086,7 @@ impl App { } let explicitly_selected = has_explicit_resume_permission_override(config, &self.harness_overrides) + || self.runtime_approvals_reviewer_override.is_some() || matches!( self.runtime_approval_policy_override, Some(RuntimeApprovalPolicyOverride::Explicit(_)) @@ -1208,6 +1236,7 @@ impl App { return; } + self.runtime_approvals_reviewer_override = Some(self.config.approvals_reviewer); self.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config(&self.config)); self.sync_active_thread_permission_settings_to_cached_session() diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index b03492d89e18..1a508b94a460 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -529,6 +529,7 @@ impl App { &self.chat_widget.config_ref().workspace_roots, ); } + fork_config.model_provider_id.clone_from(&self.chat_widget.config_ref().model_provider_id); fork_config.model = Some(self.chat_widget.current_model().to_string()); fork_config.model_reasoning_effort = self.chat_widget.current_reasoning_effort(); @@ -2462,6 +2463,7 @@ impl App { .add_error_message(format!("Failed to set permission profile: {err}")); return Ok(AppRunControl::Continue); } + self.runtime_approvals_reviewer_override = Some(self.config.approvals_reviewer); self.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config(&self.config)); self.sync_active_thread_permission_settings_to_cached_session() @@ -2475,11 +2477,9 @@ impl App { if self.reject_pending_permission_change() { return Ok(AppRunControl::Continue); } + self.runtime_approvals_reviewer_override = Some(policy); self.config.approvals_reviewer = policy; self.chat_widget.set_approvals_reviewer(policy); - if let Some(profile) = self.runtime_permission_profile_override.as_mut() { - profile.approvals_reviewer = policy; - } self.sync_active_thread_permission_settings_to_cached_session() .await; if let Err(err) = crate::config_update::write_config_batch( diff --git a/codex-rs/tui/src/app/managed_worktree_creation.rs b/codex-rs/tui/src/app/managed_worktree_creation.rs index f541836524fb..3fd35cdf7b09 100644 --- a/codex-rs/tui/src/app/managed_worktree_creation.rs +++ b/codex-rs/tui/src/app/managed_worktree_creation.rs @@ -32,6 +32,31 @@ pub(super) fn background_terminals_blocker( } } +pub(super) async fn check_background_terminals( + app_server: &mut AppServerSession, + target: &AppServerTarget, + thread_ids: impl IntoIterator, +) -> Option<&'static str> { + for thread_id in thread_ids { + let request = ClientRequest::ThreadBackgroundTerminalsList { + request_id: app_server.next_request_id(), + params: ThreadBackgroundTerminalsListParams { + thread_id: thread_id.to_string(), + cursor: None, + limit: Some(1), + }, + }; + let result = app_server + .request_handle() + .request_typed::(request) + .await; + if let Some(message) = background_terminals_blocker(result, target) { + return Some(message); + } + } + None +} + impl App { pub(super) async fn start_managed_worktree( &mut self, @@ -132,23 +157,10 @@ impl App { .iter() .filter_map(|(id, agent)| (!agent.is_closed).then_some(*id)), ); - for tracked_id in ids { - let request = ClientRequest::ThreadBackgroundTerminalsList { - request_id: app_server.next_request_id(), - params: ThreadBackgroundTerminalsListParams { - thread_id: tracked_id.to_string(), - cursor: None, - limit: Some(1), - }, - }; - let result = app_server - .request_handle() - .request_typed::(request) - .await; - if let Some(message) = background_terminals_blocker(result, &self.app_server_target) - { - return self.working_directory_error(message); - } + if let Some(message) = + check_background_terminals(app_server, &self.app_server_target, ids).await + { + return self.working_directory_error(message); } let setup = async { let source = self diff --git a/codex-rs/tui/src/app/misalignment_policy.rs b/codex-rs/tui/src/app/misalignment_policy.rs index 3f3dbad8703a..97554a030dca 100644 --- a/codex-rs/tui/src/app/misalignment_policy.rs +++ b/codex-rs/tui/src/app/misalignment_policy.rs @@ -115,12 +115,16 @@ impl App { return; }; let config = self.chat_widget.config_ref(); - let permissions_override = Self::turn_permissions_override_from_config( - config, - config.permissions.active_permission_profile().as_ref(), + let explicit_profile = self.runtime_permission_profile_override .as_ref() - .and_then(RuntimePermissionProfileOverride::turn_permission_profile), + .filter(|profile| { + profile.turn_override == RuntimePermissionProfileTurnOverride::LegacySandbox + }); + let permissions_override = Self::turn_permissions_override_from_config( + config, + explicit_profile.and_then(|profile| profile.active_permission_profile.as_ref()), + explicit_profile.and_then(RuntimePermissionProfileOverride::turn_permission_profile), ); let Ok((sandbox_policy, permissions)) = turn_permissions_overrides(permissions_override, config.cwd.as_path()) diff --git a/codex-rs/tui/src/app/new_session.rs b/codex-rs/tui/src/app/new_session.rs index fde119b1c20b..d09aaa3fd6d9 100644 --- a/codex-rs/tui/src/app/new_session.rs +++ b/codex-rs/tui/src/app/new_session.rs @@ -67,12 +67,21 @@ impl App { self.config.clone() } }; - self.apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::All); + if let Some(defaults) = defaults.as_ref() { + crate::projectless::apply_defaults( + &mut config, + &self.harness_overrides, + app_server, + &self.environment_manager, + defaults, + ); + } + self.apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::ExplicitOnly)?; config.service_tier = self.chat_widget.configured_service_tier(); if let Some(defaults) = defaults.as_ref() { overlay_new_session_defaults( &mut config, - defaults, + &defaults.config, &self.cli_kv_overrides, &self.harness_overrides, ); diff --git a/codex-rs/tui/src/app/realtime_settings.rs b/codex-rs/tui/src/app/realtime_settings.rs index ea1491fd9af8..2f31148ee65f 100644 --- a/codex-rs/tui/src/app/realtime_settings.rs +++ b/codex-rs/tui/src/app/realtime_settings.rs @@ -58,7 +58,7 @@ impl App { .await?; config .as_ref() - .map(|config| configured_voice(config, voices.default_v1)) + .map(|config| configured_voice(&config.config, voices.default_v1)) .transpose() .map(Option::flatten) } @@ -107,7 +107,7 @@ impl App { let effective = crate::config_update::read_effective_config_if_supported( app_server.request_handle(), &self.chat_widget.config_ref().cwd, ).await.and_then(|config| match config.as_ref() { - Some(config) => configured_voice(config, voices.default_v1), + Some(config) => configured_voice(&config.config, voices.default_v1), None if response.status == codex_app_server_protocol::WriteStatus::OkOverridden => Err(color_eyre::eyre::eyre!("the saved voice is overridden, but this server cannot read effective settings")), None => Ok(Some(voice)), }); diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index 61f7edc1e399..a0e8f9b1a53a 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -254,6 +254,7 @@ impl App { bootstrap, thread, } = connected; + session.model_provider_override = self.harness_overrides.model_provider.clone(); let selected = self .chat_widget .selected_index_for_present_view(agents_overview::AGENTS_OVERVIEW_VIEW_ID) @@ -324,17 +325,17 @@ impl App { .with_collaboration_modes(bootstrap.collaboration_modes), ); self.pending_app_server_requests.clear(); + // The displayed task was resumed above. Keep offscreen selections pending until those + // tasks can be resumed from the server too; their old confirmations cannot arrive. let pending_displayed_profile = - displayed.is_some_and(|id| self.pending_server_profiles.contains_key(&id)); - if pending_displayed_profile { + displayed.and_then(|id| self.pending_server_profiles.remove(&id)); + if let Some(selection) = &pending_displayed_profile { self.runtime_approval_policy_override = None; + if selection.approvals_reviewer.is_some() { + self.runtime_approvals_reviewer_override = None; + } self.runtime_permission_profile_override = None; } - // The displayed task was resumed above. Keep offscreen selections pending until those - // tasks can be resumed from the server too; their old confirmations cannot arrive. - if let Some(id) = displayed { - self.pending_server_profiles.remove(&id); - } self.pending_primary_events.clear(); self.pending_plugin_enabled_writes.clear(); self.pending_hook_enabled_writes.clear(); @@ -383,7 +384,7 @@ impl App { } if let Some(mut started) = thread { let id = started.session.thread_id; - if !pending_displayed_profile + if pending_displayed_profile.is_none() && let Some(channel) = self.thread_event_channels.get(&id) && let Some(cached) = channel.store.lock().await.session.as_ref() { diff --git a/codex-rs/tui/src/app/resume_config.rs b/codex-rs/tui/src/app/resume_config.rs index d1eea1788930..3ccce3cb473c 100644 --- a/codex-rs/tui/src/app/resume_config.rs +++ b/codex-rs/tui/src/app/resume_config.rs @@ -135,7 +135,10 @@ impl App { app_server, &mut resume_config.0, &trust_cwd, - resumed_thread.as_ref(), + crate::onboarding::DirectoryTrustOptions { + resumed_thread: resumed_thread.as_ref(), + ..Default::default() + }, /*startup_draft*/ None, ) .await?; @@ -149,7 +152,7 @@ impl App { app_server: &mut AppServerSession, config: &mut Config, cwd: &Path, - resumed_thread: Option<&codex_app_server_protocol::Thread>, + options: crate::onboarding::DirectoryTrustOptions<'_>, mut startup_draft: Option<&mut StartupDraftPump>, ) -> std::result::Result<(), AppRunControl> { // Keep the existing explicit remote --cd gate, including retries after cancellation. @@ -168,7 +171,7 @@ impl App { config, &self.app_server_target, cwd, - resumed_thread, + options, startup_draft.as_deref_mut(), ) .await @@ -177,6 +180,9 @@ impl App { AppRunControl::Continue })?; if result.should_exit { + if options.cancel == Some(crate::onboarding::TrustCancelAction::CurrentTask) { + return Err(AppRunControl::Continue); + } if matches!(self.app_server_target, AppServerTarget::Embedded) { return Err(AppRunControl::Exit(ExitReason::UserRequested)); } @@ -189,7 +195,10 @@ impl App { } return Err(AppRunControl::Continue); } - if result.directory_trust_persisted && !app_server.uses_remote_workspace() { + if !app_server.uses_remote_workspace() + && (result.directory_trust_persisted + || options.cancel == Some(crate::onboarding::TrustCancelAction::CurrentTask)) + { *config = StartupDraftPump::run_with_optional_draft( startup_draft.as_deref_mut(), tui, @@ -202,7 +211,11 @@ impl App { )); AppRunControl::Continue })?; - if resumed_thread.is_none() { + // Directory changes review hooks once, after attaching the destination. + if result.directory_trust_persisted + && options.resumed_thread.is_none() + && options.cancel != Some(crate::onboarding::TrustCancelAction::CurrentTask) + { let load_hooks = load_startup_hooks_review_entry( app_server.request_handle(), config.cwd.to_path_buf(), diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 6e73c002e4fa..545577d8403e 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -1297,19 +1297,25 @@ impl App { Ok(config) => config, Err(control) => return Ok(control), }; - let baseline_approval = resume_config.permissions.approval_policy.value(); - let baseline_permissions = RuntimePermissionProfileOverride::from_config(&resume_config); - self.apply_runtime_policy_overrides(&mut resume_config, RuntimePolicyOverrideScope::All); + if let Err(error) = self.apply_runtime_policy_overrides( + &mut resume_config, + RuntimePolicyOverrideScope::ExplicitOnly, + ) { + self.add_session_picker_error(format!("{error:#}")); + return Ok(AppRunControl::Continue); + } + let permission_overrides = self.resume_permission_overrides(&resume_config); if let Some(history_mode) = target_session.history_mode { app_server.remember_thread_history_mode(target_session.thread_id, history_mode); } let resumed = app_server - .resume_thread( + .resume_thread_with_permission_overrides( &local_settings, resume_config.clone(), target_session.thread_id, self.resume_model_settings(), + permission_overrides, ) .await; let mut history_notice = None; @@ -1381,16 +1387,22 @@ impl App { .add_info_message(notice.to_string(), /*hint*/ None); } } - if self.app_server_target.uses_remote_workspace() { - let config = self.chat_widget.config_ref(); - let approval = config.permissions.approval_policy.value(); - self.runtime_approval_policy_override = (approval != baseline_approval) - .then_some(RuntimeApprovalPolicyOverride::Restored(approval.into())); - self.runtime_permission_profile_override = (!baseline_permissions - .matches_config(config)) - .then(|| RuntimePermissionProfileOverride::from_restored_config(config)); - } self.backfill_loaded_subagent_threads(app_server).await; + if matches!( + self.runtime_approval_policy_override, + Some(RuntimeApprovalPolicyOverride::Restored(_)) + ) { + self.runtime_approval_policy_override = None; + } + if self + .runtime_permission_profile_override + .as_ref() + .is_some_and(|profile| { + profile.turn_override == RuntimePermissionProfileTurnOverride::Preserve + }) + { + self.runtime_permission_profile_override = None; + } if !read_only { self.replay_agents_overview_requests(app_server, resumed_thread_id) .await; diff --git a/codex-rs/tui/src/app/session_picker.rs b/codex-rs/tui/src/app/session_picker.rs index de09c62277a8..9152ac86cded 100644 --- a/codex-rs/tui/src/app/session_picker.rs +++ b/codex-rs/tui/src/app/session_picker.rs @@ -33,7 +33,7 @@ impl App { Ok(result) => result, Err(err) => Err(err.into()), }; - let picker_app_server = match picker_app_server { + let mut picker_app_server = match picker_app_server { Ok(app_server) => app_server, Err(err) => { self.add_session_picker_error(format!("Failed to start TUI session picker: {err}")); @@ -41,6 +41,7 @@ impl App { return Ok(AppRunControl::Continue); } }; + picker_app_server.model_provider_override = self.harness_overrides.model_provider.clone(); let selection = crate::resume_picker::run_resume_picker_from_existing_session_with_app_server( crate::uses_remote_workspace_or_environment( diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__owned_history_loading_footer.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__owned_history_loading_footer.snap index da3be283b3ca..c1cfae686263 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__owned_history_loading_footer.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__owned_history_loading_footer.snap @@ -9,7 +9,7 @@ expression: "snapshots.join(\"\\n\\n\")" ↑ Loading earlier messages… · esc latest 80 columns · Failed - Retry history: ⌥+ Result { + environments: &EnvironmentManager, +) -> Result { let defaults_cwd = match app_server.thread_params_mode() { crate::app_server_session::ThreadParamsMode::Embedded => config.cwd.as_path(), crate::app_server_session::ThreadParamsMode::Remote => { @@ -61,13 +70,21 @@ pub(super) async fn prepare_fresh_startup_config( defaults_cwd, ) .await?; + let mut prompt_windows_sandbox = false; if let Some(defaults) = defaults.as_ref() { super::new_session::overlay_new_session_defaults( config, - defaults, + &defaults.config, cli_kv_overrides, harness_overrides, ); + prompt_windows_sandbox = crate::projectless::apply_defaults( + config, + harness_overrides, + app_server, + environments, + defaults, + ); } apply_managed_new_thread_defaults( config, @@ -75,7 +92,10 @@ pub(super) async fn prepare_fresh_startup_config( cli_kv_overrides, harness_overrides, ); - Ok(defaults.is_some()) + Ok(FreshStartupDefaults { + server_defaults_read: defaults.is_some(), + prompt_windows_sandbox, + }) } pub(super) fn startup_model( @@ -212,6 +232,7 @@ impl App { let harness_overrides = normalize_harness_overrides_for_cwd(harness_overrides, &config.cwd)?; + app_server.model_provider_override = harness_overrides.model_provider.clone(); let bootstrap = match startup_bootstrap { Some(bootstrap) => bootstrap, None => match startup_draft @@ -242,7 +263,7 @@ impl App { config.model_reasoning_effort = None; } } - let server_defaults_read = if matches!( + let startup_defaults = if matches!( &session_selection, SessionSelection::StartFresh | SessionSelection::Exit ) { @@ -254,16 +275,17 @@ impl App { &app_server, &cli_kv_overrides, &harness_overrides, + &environment_manager, ), ) .await { - Ok(Ok(defaults_read)) => defaults_read, + Ok(Ok(defaults)) => defaults, Ok(Err(err)) => return shutdown_on_startup_error(app_server, err).await, Err(err) => return shutdown_on_startup_error(app_server, err).await, } } else { - false + FreshStartupDefaults::default() }; if matches!(&session_selection, SessionSelection::AgentsOverview) { apply_managed_new_thread_defaults( @@ -273,7 +295,7 @@ impl App { &harness_overrides, ); } - let mut model = startup_model(&config, &bootstrap, server_defaults_read); + let mut model = startup_model(&config, &bootstrap, startup_defaults.server_defaults_read); let available_models = bootstrap.available_models; let remote_connection = crate::status::remote_connection::remote_connection_status_value( &app_server_target, @@ -486,11 +508,15 @@ impl App { let resumed = match startup_draft .run_until( tui, - app_server.resume_thread( + app_server.resume_thread_with_permission_overrides( &local_settings, config.clone(), target_session.thread_id, model_settings, + crate::resume_permissions::ResumePermissions::from_overrides( + &config, + &harness_overrides, + ), ), ) .await @@ -525,7 +551,13 @@ impl App { Err(err) => return shutdown_on_startup_error(app_server, err).await, } } else { - let action = SessionStartAction::Resume(model_settings); + let action = SessionStartAction::Resume( + model_settings, + crate::resume_permissions::ResumePermissions::from_overrides( + &config, + &harness_overrides, + ), + ); let resumed = complete_session_start( &mut app_server, SessionStartConfig { @@ -760,6 +792,7 @@ See the Codex keymap documentation for supported actions and examples." loader_overrides, cloud_config_bundle, runtime_approval_policy_override: None, + runtime_approvals_reviewer_override: None, runtime_permission_profile_override: None, pending_server_profiles: HashMap::new(), file_search, @@ -803,7 +836,8 @@ See the Codex keymap documentation for supported actions and examples." pending_update_action: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState { - prompt_after_trust: should_prompt_windows_sandbox_nux_at_startup, + prompt_after_trust: should_prompt_windows_sandbox_nux_at_startup + || startup_defaults.prompt_windows_sandbox, ..Default::default() }, thread_event_channels: HashMap::new(), diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 2dc84abf484b..135c411dd113 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -34,6 +34,7 @@ pub(crate) async fn make_test_app() -> App { loader_overrides: LoaderOverrides::without_managed_config_for_tests(), cloud_config_bundle: CloudConfigBundleLoader::default(), runtime_approval_policy_override: None, + runtime_approvals_reviewer_override: None, runtime_permission_profile_override: None, pending_server_profiles: HashMap::new(), file_search, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 0c1fb8f4f494..65271708dc51 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -56,6 +56,8 @@ mod pagination_completion_tests; mod patch_approval_tests; #[path = "tests/permission_selection_tests.rs"] mod permission_selection_tests; +#[path = "tests/projectless_tests.rs"] +mod projectless_tests; #[path = "tests/unavailable_commands_tests.rs"] mod unavailable_commands; @@ -3083,7 +3085,7 @@ async fn server_only_profile_selection_keeps_turns_on_the_selected_profile() -> let selection = PermissionProfileSelection { profile_id: "server-only".into(), approval_policy: None, - approvals_reviewer: None, + approvals_reviewer: Some(ApprovalsReviewer::User), display_label: "server-only".into(), }; app.select_permission_profile(&mut server, selection.clone()) @@ -3096,6 +3098,9 @@ async fn server_only_profile_selection_keeps_turns_on_the_selected_profile() -> let thread_id = started.session.thread_id; app.enqueue_primary_thread_session(started.session, started.turns) .await?; + app.runtime_approvals_reviewer_override = Some(ApprovalsReviewer::AutoReview); + app.runtime_permission_profile_override = + Some(RuntimePermissionProfileOverride::from_config(&app.config)); while events.try_recv().is_ok() {} app.select_permission_profile(&mut server, selection.clone()) .await; @@ -3104,7 +3109,8 @@ async fn server_only_profile_selection_keeps_turns_on_the_selected_profile() -> @"• Permission selection requested: server-only" ); let mut tui = crate::tui::test_support::make_test_tui()?; - app.select_permission_profile(&mut server, selection).await; + app.select_permission_profile(&mut server, selection.clone()) + .await; insta::assert_snapshot!( next_history_message(&mut events), @"■ Wait for permissions to update before changing permissions." @@ -3134,6 +3140,32 @@ async fn server_only_profile_selection_keeps_turns_on_the_selected_profile() -> ) .await?; assert!(!app.pending_server_profiles.contains_key(&thread_id)); + assert_eq!( + ( + app.runtime_approvals_reviewer_override, + app.chat_widget.config_ref().approvals_reviewer + ), + (None, ApprovalsReviewer::User), + ); + app.runtime_approvals_reviewer_override = Some(ApprovalsReviewer::User); + app.select_permission_profile( + &mut server, + PermissionProfileSelection { + approvals_reviewer: None, + ..selection.clone() + }, + ) + .await; + let settings = next_thread_settings_updated(&mut server, thread_id).await; + app.enqueue_thread_notification( + thread_id, + ServerNotification::ThreadSettingsUpdated(settings), + ) + .await?; + assert_eq!( + app.runtime_approvals_reviewer_override, + Some(ApprovalsReviewer::User), + ); let endpoint = crate::resolve_remote_addr("ws://127.0.0.1:8765")?; app.app_server_target = crate::AppServerTarget::Remote { endpoint }; while events.try_recv().is_ok() {} @@ -3189,33 +3221,6 @@ async fn server_only_profile_selection_keeps_turns_on_the_selected_profile() -> Ok(()) } -#[tokio::test] -async fn resume_rejects_selected_config_profile_permission_definitions() -> Result<()> { - let home = tempdir()?; - std::fs::write( - home.path().join("config.toml"), - "default_permissions = \":workspace\"\n", - )?; - let selected = home.path().join("work.config.toml"); - std::fs::write( - &selected, - "default_permissions = \":workspace\"\n[permissions.unused]\nextends = \":read-only\"\n", - )?; - let mut loader = codex_config::LoaderOverrides::without_managed_config_for_tests(); - loader.user_config_path = Some(selected.abs()); - loader.user_config_profile = Some("work".parse()?); - let config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .loader_overrides(loader) - .build() - .await?; - assert!(config_persistence::has_explicit_resume_permission_override( - &config, - &ConfigOverrides::default() - )); - Ok(()) -} - #[tokio::test] async fn remote_resume_rejects_explicit_permission_override() -> Result<()> { let (mut app, mut events, _ops) = make_test_app_with_channels().await; @@ -3228,13 +3233,8 @@ async fn remote_resume_rejects_explicit_permission_override() -> Result<()> { )?; app.config.codex_home = home.path().to_path_buf().abs(); let mut server = crate::start_embedded_app_server_for_picker(&app.config).await?; - let selected = home.path().join("work.config.toml"); - std::fs::write( - &selected, - "[permissions.unused]\nextends = \":read-only\"\n", - )?; - app.loader_overrides.user_config_path = Some(selected.abs()); - app.loader_overrides.user_config_profile = Some("work".parse()?); + app.cli_kv_overrides + .push(("default_permissions".into(), ":workspace".into())); assert!( !config_persistence::has_explicit_resume_permission_override( &app.config, @@ -3294,6 +3294,7 @@ default_permissions = "locked-down" app.harness_overrides.sandbox_mode = Some(SandboxMode::WorkspaceWrite); app.harness_overrides.permission_profile = Some(PermissionProfile::workspace_write()); + app.set_approvals_reviewer_in_app_and_widget(ApprovalsReviewer::AutoReview); assert!( app.apply_permission_profile_selection(PermissionProfileSelection { profile_id: "locked-down".to_string(), @@ -3325,6 +3326,14 @@ default_permissions = "locked-down" app.runtime_permission_profile_override, Some(RuntimePermissionProfileOverride::from_config(&app.config)) ); + let mut destination = app.config.clone(); + destination.approvals_reviewer = ApprovalsReviewer::User; + app.apply_runtime_policy_overrides(&mut destination, RuntimePolicyOverrideScope::ExplicitOnly)?; + assert_eq!(destination.approvals_reviewer, ApprovalsReviewer::User); + assert!( + !app.resume_permission_overrides(&destination) + .approvals_reviewer + ); let op = match app_event_rx.try_recv() { Ok(AppEvent::CodexOp(op)) => op, other => panic!("expected CodexOp event, got {other:?}"), @@ -6001,6 +6010,7 @@ async fn make_test_app() -> Box { loader_overrides: LoaderOverrides::without_managed_config_for_tests(), cloud_config_bundle: CloudConfigBundleLoader::default(), runtime_approval_policy_override: None, + runtime_approvals_reviewer_override: None, runtime_permission_profile_override: None, file_search, transcript_cells: Vec::new(), @@ -6117,6 +6127,7 @@ pub(super) async fn make_test_app_with_channels() -> ( loader_overrides: LoaderOverrides::without_managed_config_for_tests(), cloud_config_bundle: CloudConfigBundleLoader::default(), runtime_approval_policy_override: None, + runtime_approvals_reviewer_override: None, runtime_permission_profile_override: None, file_search, transcript_cells: Vec::new(), diff --git a/codex-rs/tui/src/app/tests/active_reconnect_tests.rs b/codex-rs/tui/src/app/tests/active_reconnect_tests.rs index a0e061ba1b37..8f9c54761834 100644 --- a/codex-rs/tui/src/app/tests/active_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/active_reconnect_tests.rs @@ -205,6 +205,7 @@ async fn reconnect_restores_history_permissions_and_resumes_unsent_input() -> Re ); let mut tui = crate::tui::test_support::make_test_tui()?; if pending_profile { + app.runtime_approvals_reviewer_override = Some(ApprovalsReviewer::User); app.pending_server_profiles.insert( id, PermissionProfileSelection { @@ -302,6 +303,15 @@ async fn reconnect_restores_history_permissions_and_resumes_unsent_input() -> Re ); } assert!(app.pending_server_profiles.is_empty()); + if pending_profile { + assert_eq!( + app.resume_permission_overrides(&app.config), + crate::resume_permissions::ResumePermissions { + approvals_reviewer: true, + ..Default::default() + } + ); + } assert!(!app.pending_managed_worktree_creation); assert!( !app.agents_overview @@ -518,7 +528,7 @@ async fn reconnect_reconciles_offscreen_pending_profile_before_restoring_permiss "thread/read" => json!({"result": {"thread": thread(primary)}}), "turn/start" => { let params = request.params.as_ref().unwrap(); - assert_eq!(params["permissions"], "server-only"); + assert_eq!(params["permissions"], serde_json::Value::Null); assert_eq!(params["approvalPolicy"], "on-request"); assert_eq!(params["sandboxPolicy"], json!(null)); json!({"result": {"turn": {"id": "fresh", "items": [], "status": "inProgress"}}}) diff --git a/codex-rs/tui/src/app/tests/advanced_reasoning_tests.rs b/codex-rs/tui/src/app/tests/advanced_reasoning_tests.rs index 2e82c78975cc..f4cb2064f3ff 100644 --- a/codex-rs/tui/src/app/tests/advanced_reasoning_tests.rs +++ b/codex-rs/tui/src/app/tests/advanced_reasoning_tests.rs @@ -23,6 +23,7 @@ async fn fork_current_session_preserves_conversation_ultra() -> Result<()> { app.chat_widget.handle_thread_session(ThreadSessionState { windows_sandbox_host: crate::app::WindowsSandboxHost::Local, model: "gpt-5.4".to_string(), + model_provider_id: app.config.model_provider_id.clone(), reasoning_effort: Some(ReasoningEffortConfig::Ultra), ..test_thread_session(source_thread_id, test_path_buf("/tmp/project")) }); diff --git a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs index f9b572d5e836..5512cdb0e82b 100644 --- a/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs @@ -31,6 +31,17 @@ async fn confirm_permission_selection( } fn trust_launch_folder(app: &mut App) { + for cwd in [ + app.config.cwd.to_path_buf(), + app.chat_widget.config_ref().cwd.to_path_buf(), + ] { + crate::legacy_core::config::set_project_trust_level( + &app.config.codex_home, + cwd.as_path(), + codex_protocol::config_types::TrustLevel::Trusted, + ) + .expect("persist trusted fixture folder"); + } let projects = serde_json::json!({ app.config.cwd.display().to_string(): {"trust_level": "trusted"}, app.chat_widget.config_ref().cwd.display().to_string(): {"trust_level": "trusted"}, @@ -315,6 +326,7 @@ async fn command_center_new_reads_server_defaults_for_actual_destination() -> Re }, ) .await?; + server.model_provider_override = app.harness_overrides.model_provider.clone(); if launch_override { server = server.with_remote_cwd_override(Some(launch.path().to_path_buf())); } @@ -363,11 +375,11 @@ async fn command_center_new_reads_server_defaults_for_actual_destination() -> Re "." => ".".to_string(), _ => unreachable!(), }; - assert_eq!( - recorded_params(&requests, "config/read"), - vec![serde_json::json!({"cwd": cwd})], - "{mode} {expected_cwd}" - ); + let reads = recorded_params(&requests, "config/read") + .into_iter() + .filter(|params| params["includeLayers"] == true) + .collect::>(); + assert_eq!(reads.last().expect("defaults read")["cwd"], cwd); let starts = recorded_params(&requests, "thread/start"); assert_eq!(starts.len(), 1, "{mode} {expected_cwd}"); assert_eq!( @@ -385,12 +397,10 @@ async fn command_center_new_reads_server_defaults_for_actual_destination() -> Re serde_json::json!(cwd) }, &serde_json::json!(expected_model), - &if mode.starts_with("remote") { - serde_json::Value::Null - } else if mode == "local" && !explicit_cwd { - serde_json::json!("ollama") - } else { + &if mode == "local-cli-provider" || (mode == "local" && explicit_cwd) { serde_json::json!("openai") + } else { + serde_json::Value::Null }, &serde_json::json!("high"), &serde_json::json!(if mode == "local" && explicit_cwd { @@ -470,6 +480,17 @@ async fn command_center_new_preserves_explicit_choices_and_managed_defaults() -> .build() .await?; trust_launch_folder(&mut app); + for cwd in [ + app.config.cwd.as_path(), + app.chat_widget.config_ref().cwd.as_path(), + ] { + crate::legacy_core::config::set_project_trust_level( + server_home.path(), + cwd, + codex_protocol::config_types::TrustLevel::Trusted, + ) + .map_err(std::io::Error::other)?; + } let mut server_config = app.config.clone(); server_config.codex_home = server_home.path().to_path_buf().abs(); server_config.sqlite = SqliteConfig::new_for_testing(server_home.path().abs()); @@ -520,8 +541,6 @@ async fn command_center_new_read_failure_keeps_overview_and_does_not_start() -> for capability in [ HistoryCapabilities::ConfigReadFails, HistoryCapabilities::ThreadStartFails, - HistoryCapabilities::ConfigReadUnsupported(-32600), - HistoryCapabilities::ConfigReadUnsupported(-32601), ] { let (mut app, mut events, _) = make_test_app_with_channels().await; trust_launch_folder(&mut app); @@ -541,48 +560,44 @@ async fn command_center_new_read_failure_keeps_overview_and_does_not_start() -> app.local_settings.tui.status_line_use_colors = source_colors; app.new_agents_overview_session(&mut tui, &mut server, /*cwd*/ None) .await?; - let failed = matches!( - capability, - HistoryCapabilities::ConfigReadFails | HistoryCapabilities::ThreadStartFails - ); - assert_eq!(recorded_params(&requests, "config/read").len(), 1); + let reads = recorded_params(&requests, "config/read") + .into_iter() + .filter(|params| params["includeLayers"] == true) + .collect::>(); + assert!(!reads.is_empty()); + assert!(reads.iter().all(|params| params["cwd"] == reads[0]["cwd"])); assert_eq!( recorded_params(&requests, "thread/start").len(), usize::from(capability != HistoryCapabilities::ConfigReadFails) ); assert_eq!(recorded_params(&requests, "turn/start").len(), 0); - if failed { - assert_eq!(app.local_settings.tui.status_line_use_colors, source_colors); - assert!(app.agents_overview.dispatched_requests.is_empty()); - assert!( - app.chat_widget - .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) - .is_some() - ); - let error = std::iter::from_fn(|| events.try_recv().ok()) - .filter_map(|event| match event { - AppEvent::InsertHistoryCell(cell) => { - Some(lines_to_single_string(&cell.display_lines(/*width*/ 80))) - } - _ => None, - }) - .find(|message| message.contains("Failed to")) - .expect("visible read error"); - if capability == HistoryCapabilities::ConfigReadFails { - insta::assert_snapshot!(error, @"■ Failed to load new session settings: config/read failed in TUI"); - } else { - insta::assert_snapshot!( - "command_center_session_start_error", - crate::chatwidget::tests::helpers::render_bottom_popup( - &app.chat_widget, - /*width*/ 80, - ) - ); - } + assert_eq!(app.local_settings.tui.status_line_use_colors, source_colors); + assert!(app.agents_overview.dispatched_requests.is_empty()); + assert!( + app.chat_widget + .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) + .is_some() + ); + let error = std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => { + Some(lines_to_single_string(&cell.display_lines(/*width*/ 80))) + } + _ => None, + }) + .find(|message| { + message.contains("Failed to") || message.contains("Unable to check folder trust") + }) + .expect("visible read error"); + if capability == HistoryCapabilities::ConfigReadFails { + insta::assert_snapshot!(error, @"■ Unable to check folder trust: config/read failed while checking remote project trust"); } else { - assert_eq!( - recorded_params(&requests, "thread/start")[0]["model"], - "local-model" + insta::assert_snapshot!( + "command_center_session_start_error", + crate::chatwidget::tests::helpers::render_bottom_popup( + &app.chat_widget, + /*width*/ 80, + ) ); } server.shutdown().await?; @@ -978,7 +993,7 @@ async fn command_center_new_restores_blank_drafts_and_builtin_permissions() -> R ¶ms["model"] ), ( - &serde_json::json!(":read-only"), + &serde_json::Value::Null, &serde_json::json!("on-request"), &serde_json::json!("gpt-5.5") ) @@ -1048,7 +1063,7 @@ async fn command_center_new_checkout_and_worktree_preserve_source_and_default_br home.join("config.toml"), format!( "approvals_reviewer = \"auto_review\"\nsandbox_mode = \"workspace-write\"\napproval_policy = \"on-request\"\nwindows.sandbox = \"unelevated\"\n[projects.{:?}]\ntrust_level = \"trusted\"\n", - source.canonicalize()?.display().to_string(), + codex_config::loader::project_trust_key(&source), ), )?; let manager = diff --git a/codex-rs/tui/src/app/tests/disconnect_tests.rs b/codex-rs/tui/src/app/tests/disconnect_tests.rs index b47efa4265e9..b7150740b255 100644 --- a/codex-rs/tui/src/app/tests/disconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/disconnect_tests.rs @@ -324,6 +324,7 @@ where "model/list" => Some(json!({"result": {"data": [], "nextCursor": null}})), "collaborationMode/list" => Some(json!({"result": {"data": []}})), "configRequirements/read" => Some(json!({"result": {"requirements": null}})), + "config/read" => Some(json!({"error": {"code": -32601, "message": "unsupported"}})), _ => respond(request).await, }; let Some(mut response) = response else { @@ -368,6 +369,7 @@ async fn lost_initial_thread_reply_keeps_startup_draft_offline() -> Result<()> { ThreadParamsMode::Remote, /*remote_cwd_override*/ None, session.thread_tool_transport(), + /*model_provider_override*/ None, ) .await; let mut tui = crate::tui::test_support::make_test_tui()?; diff --git a/codex-rs/tui/src/app/tests/history_hydration_tests.rs b/codex-rs/tui/src/app/tests/history_hydration_tests.rs index 97c983cb66c6..9a5d34fae6f9 100644 --- a/codex-rs/tui/src/app/tests/history_hydration_tests.rs +++ b/codex-rs/tui/src/app/tests/history_hydration_tests.rs @@ -244,6 +244,7 @@ async fn history_hydration_archived_retry_uses_first_attempt_runtime_settings() for action in [ SessionStartAction::Resume( crate::app_server_session::ResumeModelSettings::RestoreFromThread, + crate::resume_permissions::ResumePermissions::default(), ), SessionStartAction::Fork(crate::app_server_session::ForkPermissionMode::InheritSaved), ] { @@ -267,7 +268,7 @@ async fn history_hydration_archived_retry_uses_first_attempt_runtime_settings() ) .await?; let initial = match action { - SessionStartAction::Resume(settings) => { + SessionStartAction::Resume(settings, _) => { server .resume_thread( &app.local_settings, diff --git a/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs b/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs index 48c7bfaf396e..c1f8ab79e2d7 100644 --- a/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs +++ b/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs @@ -37,7 +37,7 @@ fn error_notification( #[tokio::test] async fn misalignment_continuation_requires_current_review_and_submits_once() -> Result<()> { - for reject in [false, true] { + for (reject, preserve) in [(false, false), (false, true), (true, false)] { let (mut app, mut rx, _) = make_test_app_with_channels().await; let (mut server, requests, proxy) = start_recording_app_server( &app.config, @@ -57,9 +57,11 @@ async fn misalignment_continuation_requires_current_review_and_submits_once() -> session.active_permission_profile = None; app.active_thread_id = Some(thread_id); app.chat_widget.handle_thread_session(session); - app.runtime_permission_profile_override = Some( - RuntimePermissionProfileOverride::from_config(app.chat_widget.config_ref()), - ); + app.runtime_permission_profile_override = Some(if preserve { + RuntimePermissionProfileOverride::from_restored_config(app.chat_widget.config_ref()) + } else { + RuntimePermissionProfileOverride::from_config(app.chat_widget.config_ref()) + }); app.chat_widget.handle_server_notification( error_notification(thread_id, "failed-turn", policy_error()), /*replay_kind*/ None, @@ -191,9 +193,7 @@ async fn misalignment_continuation_requires_current_review_and_submits_once() -> ), approval_policy: Some(AskForApproval::OnRequest), approvals_reviewer: Some(config.approvals_reviewer.into()), - sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::ReadOnly { - network_access: false - }), + sandbox_policy: (!preserve).then(|| config.legacy_sandbox_policy().into()), input: vec![codex_app_server_protocol::UserInput::Text { text: "Continue **only** within the requested scope.\nDo not edit files." .to_string(), diff --git a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs index c3148c92f36c..457f5970b535 100644 --- a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs @@ -194,6 +194,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv let socket = tokio_tungstenite::accept_async(stream).await?; methods.extend(serve_reconnect_requests(socket, |request| std::future::ready({ match request.method.as_str() { + "config/read" => Some(json!({"error": {"code": -32601, "message": "unsupported"}})), "thread/loaded/list" if connection == 0 => None, "thread/resume" if connection == 1 && previous_thread.is_some() => None, "thread/resume" if request.params.as_ref().unwrap()["threadId"] != child.to_string() && !server_available.load(std::sync::atomic::Ordering::SeqCst) => Some(json!({"error": {"code": -32600, "message": "thread no longer exists"}})), diff --git a/codex-rs/tui/src/app/tests/new_session_tests.rs b/codex-rs/tui/src/app/tests/new_session_tests.rs index e05d5119d7cc..e77967d796b8 100644 --- a/codex-rs/tui/src/app/tests/new_session_tests.rs +++ b/codex-rs/tui/src/app/tests/new_session_tests.rs @@ -170,7 +170,9 @@ async fn replacement_uses_server_defaults_and_preserves_explicit_launch_settings ); assert_eq!( recorded_params(&requests, "config/read"), - vec![serde_json::json!({"cwd": server_config.cwd.display().to_string()})], + vec![ + serde_json::json!({"cwd": server_config.cwd.display().to_string(), "includeLayers": true}) + ], ); if explicit == "saved" { let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80) @@ -293,7 +295,7 @@ async fn replacement_preserves_remote_launch_paths_and_older_servers() -> Result .await; assert_eq!( recorded_params(&requests, "config/read"), - vec![serde_json::json!({"cwd": "."}),] + vec![serde_json::json!({"cwd": ".", "includeLayers": true}),] ); let starts = recorded_params(&requests, "thread/start"); assert_eq!(starts.len(), 1); diff --git a/codex-rs/tui/src/app/tests/permission_selection_tests.rs b/codex-rs/tui/src/app/tests/permission_selection_tests.rs index ab5ec4515554..2c66fdfe4492 100644 --- a/codex-rs/tui/src/app/tests/permission_selection_tests.rs +++ b/codex-rs/tui/src/app/tests/permission_selection_tests.rs @@ -39,6 +39,7 @@ proxy_url = "http://127.0.0.1:43128" /*replay_kind*/ None, ); assert!(app.chat_widget.is_user_turn_pending_or_running()); + app.runtime_approvals_reviewer_override = Some(ApprovalsReviewer::AutoReview); let before = RuntimePermissionProfileOverride::from_config(app.chat_widget.config_ref()); let profile_id = if before .active_permission_profile diff --git a/codex-rs/tui/src/app/tests/projectless_tests.rs b/codex-rs/tui/src/app/tests/projectless_tests.rs new file mode 100644 index 000000000000..f89622ba26f7 --- /dev/null +++ b/codex-rs/tui/src/app/tests/projectless_tests.rs @@ -0,0 +1,358 @@ +//! Exercise implicit defaults and permission inheritance through TUI session requests. + +use super::session_lifecycle_requests::HistoryCapabilities; +use super::session_lifecycle_requests::start_recording_app_server_with_history; +use super::*; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn projectless_permissions_survive_session_transitions_without_trust() -> Result<()> { + let (mut app, mut events, _) = make_test_app_with_channels().await; + let home = tempfile::tempdir()?; + app.config.codex_home = home.path().to_path_buf().abs(); + app.config.sqlite = codex_state::SqliteConfig::new_for_testing(home.path().abs()); + let source = tempfile::tempdir()?; + let destination = tempfile::tempdir()?; + let model = core_test_support::responses::start_mock_server().await; + let response = core_test_support::responses::mount_sse_once( + &model, + core_test_support::responses::sse(vec![ + core_test_support::responses::ev_response_created("saved-permissions"), + core_test_support::responses::ev_completed("saved-permissions"), + ]), + ) + .await; + std::fs::write( + home.path().join("config.toml"), + format!( + r#"model = "gpt-5.2" +model_provider = "test" +[model_providers.test] +name = "test" +base_url = "{}/v1" +wire_api = "responses" +[windows] +sandbox = "unelevated" +"#, + model.uri() + ), + )?; + app.config = app + .rebuild_config_for_cwd(source.path().to_path_buf()) + .await?; + app.chat_widget + .handle_thread_session_quiet(test_thread_session( + ThreadId::new(), + source.path().to_path_buf(), + )); + let mut server = crate::start_embedded_app_server_for_picker(&app.config).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + tui.pause_events(); + app.start_fresh_session( + &mut tui, + &mut server, + /*session_start_source*/ None, + /*initial_user_message*/ None, + /*new_thread_name*/ None, + ) + .await; + assert!( + app.config + .permissions + .permission_profile() + .file_system_sandbox_policy() + .can_write_local_path_with_cwd(source.path(), source.path()) + ); + assert!(matches!( + app.config.permissions.approval_policy.value(), + codex_protocol::protocol::AskForApproval::Granular(_) + )); + // Saving history must not turn implicit writable defaults into read-only on /cd. + server + .thread_inject_items( + app.chat_widget.thread_id().expect("fresh task"), + vec![App::side_boundary_prompt_item()], + ) + .await?; + let fresh_destination = tempfile::tempdir()?; + for cwd in [fresh_destination.path(), source.path()] { + assert!( + app.chat_widget + .rollout_path() + .as_deref() + .is_some_and(rollout_path_is_resumable) + ); + app.change_working_directory(&mut tui, &mut server, cwd.abs()) + .await; + assert_eq!(app.config.cwd.as_path(), cwd); + assert_eq!( + app.config.permissions.active_permission_profile(), + Some(ActivePermissionProfile::new(":workspace")) + ); + assert!(matches!( + app.config.permissions.approval_policy.value(), + codex_protocol::protocol::AskForApproval::Granular(_) + )); + } + let mut readonly = app.config.clone(); + readonly + .permissions + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( + PermissionProfile::read_only(), + ActivePermissionProfile::new(":read-only"), + ))?; + let saved = server.start_thread(&readonly).await?; + server + .thread_inject_items( + saved.session.thread_id, + vec![App::side_boundary_prompt_item()], + ) + .await?; + tui.pause_events(); + app.launch_cwd = source.path().to_path_buf(); + app.resume_target_session( + &mut tui, + &mut server, + SessionTarget { + path: None, + thread_id: saved.session.thread_id, + cwd: Some(source.path().to_path_buf()), + history_mode: None, + }, + ) + .await?; + assert_eq!(app.chat_widget.thread_id(), Some(saved.session.thread_id)); + assert_eq!( + app.config.permissions.active_permission_profile(), + Some(ActivePermissionProfile::new(":read-only")) + ); + // A normal turn must leave the saved profile on the server, even though the + // widget knows its ID. Complete the turn so /cd exercises saved history. + while events.try_recv().is_ok() {} + app.chat_widget.apply_external_edit("hello".into()); + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let op = std::iter::from_fn(|| events.try_recv().ok()) + .find_map(|event| match event { + AppEvent::CodexOp(op @ AppCommand::UserTurn { .. }) => Some(op), + _ => None, + }) + .expect("user turn"); + assert!( + app.try_submit_active_thread_op_via_app_server(&mut server, saved.session.thread_id, &op) + .await? + ); + tokio::time::timeout(Duration::from_secs(/*secs*/ 30), async { + loop { + let page = server + .thread_turns_page( + saved.session.thread_id, + /*cursor*/ None, + /*limit*/ 1, + ) + .await?; + if page + .data + .first() + .is_some_and(|turn| turn.status == codex_app_server_protocol::TurnStatus::Completed) + { + return Ok::<_, color_eyre::Report>(()); + } + tokio::time::sleep(Duration::from_millis(/*millis*/ 10)).await; + } + }) + .await??; + response.single_request(); + let next = app.load_new_session_config(&server).await?; + assert_eq!( + next.permissions.permission_profile(), + &PermissionProfile::workspace_write() + ); + let before = app.chat_widget.thread_id(); + app.change_working_directory( + &mut tui, + &mut server, + destination.path().to_path_buf().abs(), + ) + .await; + assert_eq!(app.config.cwd.as_path(), destination.path()); + assert_ne!(app.chat_widget.thread_id(), before); + assert!( + !app.config + .permissions + .effective_permission_profile() + .file_system_sandbox_policy() + .can_write_local_path_with_cwd(destination.path(), destination.path()) + ); + assert!(!std::fs::read_to_string(home.path().join("config.toml"))?.contains("trust_level")); + let untrusted = tempfile::tempdir()?; + let config_path = home.path().join("config.toml"); + std::fs::write( + &config_path, + format!( + "{}\n[projects.{}]\ntrust_level = \"untrusted\"\n", + std::fs::read_to_string(&config_path)?, + serde_json::to_string(untrusted.path())? + ), + )?; + app.change_working_directory(&mut tui, &mut server, untrusted.path().abs()) + .await; + assert_eq!( + ( + app.config.cwd.as_path(), + app.config.permissions.approval_policy.value() + ), + ( + untrusted.path(), + codex_protocol::protocol::AskForApproval::UnlessTrusted + ) + ); + // A new task in a marked project still gets implicit defaults when leaving it. + std::fs::create_dir(untrusted.path().join(".codex"))?; + std::fs::write(untrusted.path().join(".codex/config.toml"), "")?; + crate::config_update::write_trusted_project(server.request_handle(), untrusted.path()).await?; + app.new_agents_overview_session(&mut tui, &mut server, Some(untrusted.path().abs())) + .await?; + server + .thread_inject_items( + app.chat_widget.thread_id().expect("new project task"), + vec![App::side_boundary_prompt_item()], + ) + .await?; + app.change_working_directory(&mut tui, &mut server, source.path().abs()) + .await; + assert_eq!( + ( + app.config.cwd.as_path(), + app.config.permissions.active_permission_profile() + ), + ( + source.path(), + Some(ActivePermissionProfile::new(":workspace")) + ) + ); + server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn local_projectless_defaults_respect_trust_scope_and_explicit_settings() -> Result<()> { + use codex_protocol::protocol::AskForApproval as Approval; + for (settings, project_marker, expected_profile, expected_approval, sandbox_ready) in [ + ("", false, ":workspace", None, true), + #[cfg(target_os = "windows")] + ("", false, ":read-only", None, false), + ("", true, ":read-only", None, true), + ( + "sandbox_mode = \"read-only\"\n", + false, + ":read-only", + None, + true, + ), + ( + "sandbox_mode = \"read-only\"\n", + false, + ":read-only", + None, + false, + ), + ( + "default_permissions = \":read-only\"\n", + false, + ":read-only", + None, + false, + ), + ( + "approval_policy = \"on-request\"\n", + false, + ":workspace", + Some(Approval::OnRequest), + true, + ), + ] { + let home = tempfile::tempdir()?; + let parent = tempfile::tempdir()?; + let cwd = parent.path().join("notes"); + std::fs::create_dir(&cwd)?; + let parent_path = serde_json::to_string(parent.path())?; + let windows = if sandbox_ready { + "[windows]\nsandbox = \"unelevated\"\n" + } else { + "" + }; + std::fs::write( + home.path().join("config.toml"), + format!("{settings}{windows}[projects.{parent_path}]\ntrust_level = \"untrusted\"\n"), + )?; + let overrides = ConfigOverrides { + cwd: Some(cwd), + ..Default::default() + }; + let mut config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .harness_overrides(overrides.clone()) + .build() + .await?; + let original_approval = if settings.is_empty() && !project_marker { + Approval::Granular(codex_protocol::protocol::GranularApprovalConfig { + sandbox_approval: false, + rules: false, + skill_approval: false, + request_permissions: true, + mcp_elicitations: true, + }) + } else { + config.permissions.approval_policy.value() + }; + if project_marker { + // The server must notice a project layer added after client discovery. + assert!(config.config_layer_stack.is_projectless()); + std::fs::create_dir(config.cwd.join(".codex"))?; + std::fs::write( + config.cwd.join(".codex/config.toml"), + "model = \"untrusted-model\"\n", + )?; + } + let (server, _, proxy) = start_recording_app_server_with_history( + &config, + HistoryCapabilities::Current, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Embedded, + LoaderOverrides::without_managed_config_for_tests(), + ) + .await?; + let startup = crate::app::startup::prepare_fresh_startup_config( + &mut config, + &server, + &[], + &overrides, + &EnvironmentManager::default_for_tests(), + ) + .await?; + assert_eq!( + startup.prompt_windows_sandbox, + cfg!(target_os = "windows") && !sandbox_ready && settings.is_empty() && !project_marker + ); + assert_eq!( + ( + config.permissions.permission_profile(), + config.permissions.approval_policy.value() + ), + ( + &if expected_profile == ":workspace" { + PermissionProfile::workspace_write() + } else { + PermissionProfile::read_only() + }, + expected_approval.unwrap_or(original_approval) + ) + ); + server.shutdown().await?; + proxy.await??; + } + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index c4ceee02cfb7..0fbf795370a9 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -190,7 +190,7 @@ pub(super) async fn start_recording_app_server( blocked_thread_list, failed_thread_name, crate::app_server_session::ThreadParamsMode::Embedded, - LoaderOverrides::default(), + LoaderOverrides::without_managed_config_for_tests(), ) .await } @@ -857,6 +857,7 @@ async fn external_transport_registers_dynamic_tools_and_finds_task_mentions() -> crate::app_server_session::ThreadParamsMode::Embedded, /*remote_cwd_override*/ None, app_server.thread_tool_transport(), + /*model_provider_override*/ None, ) .await?; assert!(startup.task_tools_available); @@ -1114,6 +1115,7 @@ async fn local_daemon_registers_approval_gated_mcp_tools_for_both_start_paths() crate::app_server_session::ThreadParamsMode::Embedded, /*remote_cwd_override*/ None, app_server.thread_tool_transport(), + /*model_provider_override*/ None, ) .await?; assert!(startup.task_tools_available); @@ -1495,6 +1497,7 @@ async fn older_external_server_starts_without_unsupported_dynamic_tools_or_histo crate::app_server_session::ThreadParamsMode::Embedded, /*remote_cwd_override*/ None, app_server.thread_tool_transport(), + /*model_provider_override*/ None, ) .await?; assert!(!startup.task_tools_available); @@ -1764,8 +1767,11 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac }, }) .await?; - assert!(source_settings.active_permission_profile.is_none()); - let source_sandbox = serde_json::to_value(source_settings.sandbox)?; + let source_sandbox = if source_settings.active_permission_profile.is_some() { + serde_json::Value::Null + } else { + serde_json::to_value(source_settings.sandbox)? + }; spawn_approved_task_tool_call( &app, &app_server, @@ -1831,6 +1837,16 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac .expect("background task creation")["projectId"], project.project.id ); + assert_eq!( + recorded_params(&requests, "thread/start") + .last() + .expect("background task creation")["permissions"], + serde_json::json!( + source_settings + .active_permission_profile + .map(|profile| profile.id) + ) + ); let turn = recorded_params(&requests, "turn/start") .pop() .expect("background task turn request"); @@ -3554,8 +3570,9 @@ terminal_visualization_instructions = true params[0]["modelProvider"].as_str(), params[0]["config"]["model_reasoning_effort"].as_str(), ], - [Some("gpt-5.2"), Some("ollama"), Some("high")] + [Some("gpt-5.2"), fork.then_some("ollama"), Some("high")] ); + assert_eq!(app.config.model_provider_id, "ollama"); // Injection flushes and materializes a new thread's otherwise lazy rollout. server.thread_inject_items(replacement, vec![serde_json::from_value(serde_json::json!({ "type": "message", "role": "assistant", "content": [{"type": "output_text", "text": format!("replacement persistence probe {mode:?}")}] @@ -3805,7 +3822,7 @@ async fn changing_directory_preserves_project_trust_permissions_history_and_hook let requirements = codex_home.path().join("requirements.toml"); let rules = "allowed_approval_policies=[\"untrusted\"]\nallowed_sandbox_modes=[\"read-only\"]"; fs::write(&requirements, rules)?; - fs::create_dir_all(unknown.join(".git"))?; + fs::create_dir_all(unknown.join(".codex"))?; for dir in [&trusted, &untrusted, &mismatch, &failed] { let trust = [T::Trusted, T::Untrusted][usize::from(dir == &untrusted)]; crate::legacy_core::config::set_project_trust_level(codex_home.path(), dir, trust) @@ -3851,6 +3868,30 @@ async fn changing_directory_preserves_project_trust_permissions_history_and_hook thread_id, requested_cwd: path.into(), }; + let mut stale_config = app.rebuild_config_for_cwd(untrusted.clone()).await?; + stale_config.active_project.trust_level = None; + app.app_server_target = crate::AppServerTarget::LocalDaemon { + endpoint: crate::resolve_remote_addr("ws://127.0.0.1:8765")?, + allow_embedded_fallback: false, + }; + history(); + assert!( + app.confirm_directory_trust( + &mut tui, + &mut server, + &mut stale_config, + &untrusted, + crate::onboarding::DirectoryTrustOptions { + cancel: Some(crate::onboarding::TrustCancelAction::CurrentTask), + ..Default::default() + }, + /*startup_draft*/ None, + ) + .await + .is_err() + ); + assert_snapshot!(history().join(""), @"■ Unable to check folder trust: Folder trust changed. Reopen the destination with its restricted settings."); + app.app_server_target = crate::AppServerTarget::Embedded; for (path, kind, expected) in [ ("missing", "local", "Cannot access directory"), ("../config.toml", "local", "Not a directory"), @@ -3860,9 +3901,12 @@ async fn changing_directory_preserves_project_trust_permissions_history_and_hook ("../trusted", "running", "another agent is running"), ("../trusted", "active", "another agent is running"), ("../trusted", "mcp", "inventory is still loading"), + ("../unknown", "main", "background terminals"), + ("../unknown", "child", "background terminals"), ("../trusted", "approval", "approval policy override"), ("../trusted", "profile", "permission profile override"), ("../trusted", "reviewer", "reviewer"), + ("../trusted", "standalone_reviewer", "reviewer"), ("../p", "named", "different settings"), ( "../trusted", @@ -3870,19 +3914,22 @@ async fn changing_directory_preserves_project_trust_permissions_history_and_hook "Permission profile cannot be preserved", ), ("../p", "keymap", "open_transcript"), - ("../unknown", "local", "This directory is not trusted"), - ("../trusted", "main", "background terminals"), - ("../trusted", "child", "background terminals"), ] { app.config.approvals_reviewer = ApprovalsReviewer::User; - if kind == "reviewer" { + if matches!(kind, "reviewer" | "standalone_reviewer") { app.config.approvals_reviewer = ApprovalsReviewer::AutoReview; fs::write(&requirements, "allowed_approvals_reviewers = [\"user\"]")?; } app.agent_navigation.set_running(child, kind == "running"); store.lock().await.active_turn_id = (kind == "active").then(|| "active".into()); - app.loader_overrides.system_requirements_path = - matches!(kind, "approval" | "profile" | "reviewer").then_some(requirements.clone()); + app.loader_overrides.system_requirements_path = matches!( + kind, + "approval" | "profile" | "reviewer" | "standalone_reviewer" + ) + .then_some(requirements.clone()); + app.runtime_approvals_reviewer_override = + matches!(kind, "reviewer" | "standalone_reviewer") + .then_some(ApprovalsReviewer::AutoReview); app.harness_overrides.permission_profile = (kind != "named").then_some(PermissionProfile::workspace_write()); app.runtime_approval_policy_override = (kind == "approval").then_some( @@ -3925,9 +3972,14 @@ async fn changing_directory_preserves_project_trust_permissions_history_and_hook assert_eq!(app.chat_widget.thread_id(), Some(original)); assert_eq!(app.config.cwd, current.clone().abs()); assert!(app.runtime_working_directory_override.is_none()); - let count = requests.lock().expect("request recorder lock").len(); - let checked = usize::from(kind == "main") + 2 * usize::from(kind == "child"); - assert_eq!(count, checked, "{kind}"); + assert!( + recorded_params(&requests, "thread/start").is_empty(), + "{kind}" + ); + assert!( + recorded_params(&requests, "thread/fork").is_empty(), + "{kind}" + ); let listed = recorded_params(&requests, "thread/backgroundTerminals/list"); let mut ids = listed.iter().zip([original, child]); assert!(ids.all(|(p, id)| p["threadId"] == id.to_string())); @@ -3984,6 +4036,7 @@ async fn changing_directory_preserves_project_trust_permissions_history_and_hook child, /*agent_nickname*/ None, /*agent_role*/ None, /*is_closed*/ false, ); app.set_approvals_reviewer_in_app_and_widget(ApprovalsReviewer::AutoReview); + app.runtime_approvals_reviewer_override = Some(ApprovalsReviewer::AutoReview); app.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config(&app.config)); for (path, expected) in [(&failed, 0), (&trusted, 2)] { @@ -4021,6 +4074,7 @@ async fn changing_directory_preserves_project_trust_permissions_history_and_hook let configured = app.primary_session_configured.as_ref().expect("session"); let source = codex_utils_path_uri::PathUri::from_abs_path(&agents.abs()); assert!(configured.instruction_source_paths.contains(&source)); + assert_eq!(configured.approvals_reviewer, ApprovalsReviewer::AutoReview); let (cwd, result) = (current.clone(), Err("stale skills".into())); let skills = AppEvent::SkillsListLoaded { cwd, result }; let (cwd, plugins) = (current.clone(), Some(vec![])); @@ -4073,11 +4127,18 @@ async fn changing_directory_preserves_project_trust_permissions_history_and_hook .handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); app.harness_overrides.bypass_hook_trust = Some(true); requests.lock().expect("request recorder lock").clear(); - app.change_working_directory(&mut tui, &mut server, trusted.abs()) + app.change_working_directory(&mut tui, &mut server, trusted.clone().abs()) .await; assert!(app.config.bypass_hook_trust && !app.chat_widget.has_active_view()); assert!(recorded_params(&requests, "hooks/list").is_empty()); app.harness_overrides.bypass_hook_trust = None; + app.harness_overrides.permission_profile = None; + app.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Restored( + AskForApproval::OnRequest, + )); + app.runtime_permission_profile_override = Some( + RuntimePermissionProfileOverride::from_restored_config(&app.config), + ); requests.lock().expect("request recorder lock").clear(); app.change_working_directory(&mut tui, &mut server, untrusted.clone().abs()) .await; @@ -4087,6 +4148,20 @@ async fn changing_directory_preserves_project_trust_permissions_history_and_hook assert_eq!(rec(req, "thread/fork")[0]["approvalPolicy"], "untrusted"); let warning = "Project-local config, hooks, and exec policies are disabled"; assert!(history().iter().any(|line| line.contains(warning))); + let untrusted_permissions = app.config.permissions.permission_profile().clone(); + app.change_working_directory(&mut tui, &mut server, trusted.clone().abs()) + .await; + assert_eq!(app.config.cwd, trusted.abs()); + assert_eq!( + ( + app.config.permissions.approval_policy.value(), + app.config.permissions.permission_profile() + ), + ( + AskForApproval::UnlessTrusted.to_core(), + &untrusted_permissions + ) + ); server.shutdown().await?; proxy.await??; Ok(()) @@ -4370,7 +4445,7 @@ fn session_lifecycle_avoids_redundant_subagent_metadata_reads() -> Result<()> { .replace(&child_thread_id.to_string(), "[child]"), @r###" Subagents - Select an agent to watch. ⌥+← previous, ⌥+→ next. + Select an agent to watch. ⌥← previous, ⌥→ next. › 1. • Main [default] (current) [root] @@ -4679,6 +4754,12 @@ async fn command_center_read_only_open_requests_and_failure_preservation() -> Re let (tx, mut events) = tokio::sync::mpsc::unbounded_channel(); app.app_event_tx = AppEventSender::new(tx); + app.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Restored( + AskForApproval::Never, + )); + app.runtime_permission_profile_override = Some( + RuntimePermissionProfileOverride::from_restored_config(&app.config), + ); Box::pin(app.select_agents_overview_thread(&mut tui, &mut server, thread_id)).await?; assert_eq!(recorded_params(&requests, "thread/resume").len(), 1); assert!(recorded_params(&requests, "turn/start").is_empty()); @@ -4706,6 +4787,13 @@ async fn command_center_read_only_open_requests_and_failure_preservation() -> Re } else { assert_eq!(app.current_displayed_thread_id(), Some(thread_id)); assert!(app.chat_widget.is_external_writer_view()); + assert_eq!( + ( + app.runtime_approval_policy_override, + app.runtime_permission_profile_override.as_ref() + ), + (None, None) + ); assert_eq!( app.thread_event_channels[&thread_id].attachment(), ThreadEventAttachment::ExternalWriter diff --git a/codex-rs/tui/src/app/tests/startup_defaults_tests.rs b/codex-rs/tui/src/app/tests/startup_defaults_tests.rs index 5e2409e78e18..429265902a22 100644 --- a/codex-rs/tui/src/app/tests/startup_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/startup_defaults_tests.rs @@ -256,8 +256,10 @@ async fn fresh_startup_uses_server_defaults_with_explicit_and_managed_precedence &server, &cli_kv_overrides, &harness_overrides, + &EnvironmentManager::default_for_tests() ) .await? + .server_defaults_read ); let selected_model = startup_model(&config, &bootstrap, /*server_defaults_read*/ true); let started = crate::app_server_session::start_thread_with_request_handle( @@ -267,6 +269,7 @@ async fn fresh_startup_uses_server_defaults_with_explicit_and_managed_precedence server.thread_params_mode(), server.remote_cwd_override().map(Path::to_path_buf), server.thread_tool_transport(), + /*model_provider_override*/ None, ) .await?; assert_eq!(selected_model, expected_model, "{choice}"); @@ -285,8 +288,13 @@ async fn fresh_startup_uses_server_defaults_with_explicit_and_managed_precedence ); assert_eq!(started.session.model, expected_model, "{choice}"); assert_eq!( - recorded_params(&requests, "config/read"), - vec![serde_json::json!({"cwd": server_config.cwd.display().to_string()})], + recorded_params(&requests, "config/read") + .into_iter() + .filter(|params| !params["cwd"].is_null()) + .collect::>(), + vec![ + serde_json::json!({"cwd": server_config.cwd.display().to_string(), "includeLayers": true}) + ], ); server.shutdown().await?; proxy.await??; @@ -307,7 +315,7 @@ async fn fresh_startup_reads_destination_and_cleared_model_uses_catalog() -> Res )?; std::fs::write( server_home.path().join("config.toml"), - "model_reasoning_effort = \"high\"\n", + "model_reasoning_effort = \"high\"\nsandbox_mode = \"read-only\"\n", )?; let mut config = ConfigBuilder::default() .codex_home(client_home.path().to_path_buf()) @@ -341,14 +349,21 @@ async fn fresh_startup_reads_destination_and_cleared_model_uses_catalog() -> Res if override_cwd { server = server.with_remote_cwd_override(Some(launch_cwd.path().to_path_buf())); } + assert!(config.config_layer_stack.is_projectless()); let bootstrap = server.bootstrap(&config).await?; assert_eq!(bootstrap.default_model, "stale-client-model"); - let defaults_read = - prepare_fresh_startup_config(&mut config, &server, &[], &ConfigOverrides::default()) - .await?; - assert!(defaults_read); + let defaults_read = prepare_fresh_startup_config( + &mut config, + &server, + &[], + &ConfigOverrides::default(), + &EnvironmentManager::default_for_tests(), + ) + .await?; + assert!(defaults_read.server_defaults_read); + assert!(!defaults_read.prompt_windows_sandbox); assert_eq!(config.model, None); - let selected_model = startup_model(&config, &bootstrap, defaults_read); + let selected_model = startup_model(&config, &bootstrap, defaults_read.server_defaults_read); assert_ne!(selected_model, "stale-client-model"); let started = crate::app_server_session::start_thread_with_request_handle( server.request_handle(), @@ -357,9 +372,20 @@ async fn fresh_startup_reads_destination_and_cleared_model_uses_catalog() -> Res server.thread_params_mode(), server.remote_cwd_override().map(Path::to_path_buf), server.thread_tool_transport(), + /*model_provider_override*/ None, ) .await?; assert_eq!(started.session.model, selected_model); + assert!( + !started + .session + .permission_profile + .file_system_sandbox_policy() + .can_write_local_path_with_cwd( + started.session.cwd.as_path(), + started.session.cwd.as_path() + ) + ); let starts = recorded_params(&requests, "thread/start"); assert_eq!(starts.len(), 1); assert_eq!(starts[0]["model"], serde_json::Value::Null); @@ -382,9 +408,14 @@ async fn fresh_startup_reads_destination_and_cleared_model_uses_catalog() -> Res } else { ".".to_string() }; - assert_eq!( - recorded_params(&requests, "config/read"), - vec![serde_json::json!({"cwd": expected_cwd})] + let reads = recorded_params(&requests, "config/read") + .into_iter() + .filter(|params| !params["cwd"].is_null()) + .collect::>(); + assert!(!reads.is_empty()); + assert!( + reads.iter().all(|params| params["cwd"] == expected_cwd), + "{reads:?}" ); server.shutdown().await?; proxy.await??; diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 78178575b1c2..341eaa057c79 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -739,7 +739,7 @@ impl App { cwd, approval_policy, approvals_reviewer, - active_permission_profile, + active_permission_profile: _, model, effort, summary, @@ -830,10 +830,6 @@ impl App { let selected_profile = self.pending_server_profiles.get(&thread_id); let selected_active = selected_profile .map(|profile| ActivePermissionProfile::new(profile.profile_id.clone())); - let confirmed_active = (self.app_server_target.thread_params_mode() - == crate::app_server_session::ThreadParamsMode::Remote) - .then(|| config.permissions.active_permission_profile()) - .flatten(); let (turn_approval_policy, turn_approvals_reviewer) = if let Some(profile) = selected_profile { ( @@ -857,14 +853,21 @@ impl App { ), ) }; + // Only explicit choices may override the saved server profile. + let explicit_profile = self + .runtime_permission_profile_override + .as_ref() + .filter(|profile| { + profile.turn_override + == RuntimePermissionProfileTurnOverride::LegacySandbox + }); let permissions_override = Self::turn_permissions_override_from_config( config, - selected_active - .as_ref() - .or(confirmed_active.as_ref()) - .or(active_permission_profile.as_ref()), - self.runtime_permission_profile_override - .as_ref() + selected_active.as_ref().or_else(|| { + explicit_profile + .and_then(|profile| profile.active_permission_profile.as_ref()) + }), + explicit_profile .and_then(RuntimePermissionProfileOverride::turn_permission_profile), ); let response = app_server @@ -1186,7 +1189,7 @@ impl App { { return Ok(()); } - let mut permission_change_confirmed = false; + let mut confirmed_profile = None; if let ServerNotification::ThreadSettingsUpdated(notification) = ¬ification { self.apply_thread_settings_to_cached_session(thread_id, ¬ification.thread_settings) .await; @@ -1207,8 +1210,7 @@ impl App { }) }) { - self.pending_server_profiles.remove(&thread_id); - permission_change_confirmed = true; + confirmed_profile = self.pending_server_profiles.remove(&thread_id); } } let inferred_session = if let ServerNotification::ThreadStarted(started) = ¬ification @@ -1296,7 +1298,13 @@ impl App { .on_thread_settings_updated(settings.clone()); notification = None; } - if permission_change_confirmed { + if let Some(selected) = confirmed_profile { + if self.chat_widget.thread_id() == Some(thread_id) { + if selected.approvals_reviewer.is_some() { + self.runtime_approvals_reviewer_override = None; + } + self.runtime_permission_profile_override = None; + } if self.chat_widget.thread_id() == Some(thread_id) && let Some(profile) = self .chat_widget @@ -1322,6 +1330,7 @@ impl App { Some(RuntimeApprovalPolicyOverride::Explicit( self.config.permissions.approval_policy.value().into(), )); + self.runtime_approvals_reviewer_override = Some(self.config.approvals_reviewer); self.runtime_permission_profile_override = Some(RuntimePermissionProfileOverride::from_config(&self.config)); } @@ -1554,6 +1563,14 @@ impl App { tracing::warn!(%err, "failed to sync app permissions from thread session"); } self.config.approvals_reviewer = session.approvals_reviewer; + if !self.app_server_target.uses_remote_workspace() { + self.config + .workspace_roots + .clone_from(&session.runtime_workspace_roots); + self.config + .permissions + .set_workspace_roots(session.runtime_workspace_roots.clone()); + } let thread_id = session.thread_id; self.pending_server_profiles.remove(&thread_id); diff --git a/codex-rs/tui/src/app/working_directory.rs b/codex-rs/tui/src/app/working_directory.rs index 36b47659473c..76187b34a36a 100644 --- a/codex-rs/tui/src/app/working_directory.rs +++ b/codex-rs/tui/src/app/working_directory.rs @@ -1,14 +1,13 @@ //! Local directory transitions and managed worktrees with fresh or preserved conversation history. //! Managed transitions and widget attachment run separately at the top of the event loop. +use super::managed_worktree_creation::check_background_terminals; use super::session_lifecycle::ThreadAttachPresentation; use super::*; use crate::app_event::ManagedWorktreeTransition; use crate::app_server_session::ForkGoalContinuation::DeferUntilNextTurn; use crate::history_cell::McpInventoryLoadingCell as LoadingCell; use crate::terminal_visualization_instructions::with_terminal_visualization_instructions; -use codex_app_server_protocol::ThreadBackgroundTerminalsListParams; -use codex_app_server_protocol::ThreadBackgroundTerminalsListResponse as ListResponse; enum DestinationConfig { Load, @@ -202,6 +201,42 @@ impl App { .iter() .filter_map(|(id, agent)| (!agent.is_closed).then_some(*id)) .collect(); + let is_new_worktree = matches!( + managed_worktree.as_ref().map(|(_, _, mode, _)| mode), + Some(crate::app_event::ManagedWorktreeMode::New) + ); + let rollout = self.chat_widget.rollout_path(); + let mut has_rollout = rollout.as_deref().is_some_and(rollout_path_is_resumable); + let channels = &self.thread_event_channels; + if !has_rollout + && !is_new_worktree + && (!self.chat_widget.token_usage().is_zero() + || channels.get(&thread_id).is_some_and(|channel| { + channel.store.try_lock().map_or(/*default*/ true, |store| { + !store.turns.is_empty() + || store.buffer.iter().any(|event| { + matches!( + event, + ThreadBufferedEvent::Notification(notification) + if matches!(notification.as_ref(), + ServerNotification::TurnStarted(_) + | ServerNotification::TurnCompleted(_)) + ) + }) + }) + })) + { + return self.working_directory_error("Conversation history is not saved."); + } + let mut ids: HashSet<_> = channels + .keys() + .copied() + .filter(|id| !closed_agents.contains(id)) + .collect(); + ids.extend(open_agents); + let mut tracked_ids: Vec<_> = std::iter::once(thread_id) + .chain(ids.iter().copied().filter(|id| *id != thread_id)) + .collect(); let mut config = match destination_config { DestinationConfig::Prepared(config) => *config, DestinationConfig::Load => match self.rebuild_config_for_cwd(cwd.to_path_buf()).await { @@ -212,7 +247,32 @@ impl App { }, }; if config.active_project.trust_level.is_none() { - return self.working_directory_error("This directory is not trusted; run Codex there."); + if let Some(message) = check_background_terminals( + app_server, + &self.app_server_target, + tracked_ids.iter().copied(), + ) + .await + { + return self.working_directory_error(message); + } + if self + .confirm_directory_trust( + tui, + app_server, + &mut config, + cwd.as_path(), + crate::onboarding::DirectoryTrustOptions { + cancel: Some(crate::onboarding::TrustCancelAction::CurrentTask), + ..Default::default() + }, + /*startup_draft*/ None, + ) + .await + .is_err() + { + return; + } } if let Some((_, checkout, crate::app_event::ManagedWorktreeMode::Fork, _)) = managed_worktree.as_ref() @@ -229,15 +289,64 @@ impl App { checkout.root.display() )); } - if let Some(profile) = self.runtime_permission_profile_override.as_ref() - && profile.active_permission_profile.is_some() + if !has_rollout + || is_new_worktree + || (self.runtime_permission_profile_override.is_none() + && self + .config + .permissions + .active_permission_profile() + .is_some_and(|profile| { + profile.id == codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE + })) + { + match crate::config_update::read_effective_config_if_supported( + app_server.request_handle(), + cwd.as_path(), + ) + .await + { + Ok(Some(defaults)) => { + crate::projectless::apply_defaults( + &mut config, + &self.harness_overrides, + app_server, + &self.environment_manager, + &defaults, + ); + } + Ok(None) => {} + Err(error) => { + return self + .working_directory_error(format!("Cannot read defaults: {error:#}")); + } + } + } + let scope = if config.active_project.is_untrusted() { + RuntimePolicyOverrideScope::ExplicitOnly + } else { + RuntimePolicyOverrideScope::All + }; + let profile = self + .runtime_permission_profile_override + .as_ref() + .filter(|profile| { + scope == RuntimePolicyOverrideScope::All + || profile.turn_override == RuntimePermissionProfileTurnOverride::LegacySandbox + }) + .cloned(); + if let Some(profile) = profile.as_ref() + && profile + .active_permission_profile + .as_ref() + .is_some_and(|active| !active.id.starts_with(':')) && (!profile.matches_config(&config) || config.permissions.profile_workspace_roots() != self.config.permissions.profile_workspace_roots()) { return self.working_directory_error("Permission profile has different settings."); } - if let Some(profile) = self.runtime_permission_profile_override.as_ref() + if let Some(profile) = profile.as_ref() && profile.turn_override == RuntimePermissionProfileTurnOverride::Preserve && profile.active_permission_profile.is_none() && !crate::app_server_session::permission_profile_is_safely_represented_by_sandbox_mode( @@ -247,20 +356,17 @@ impl App { { return self.working_directory_error("Permission profile cannot be preserved by /cd."); } - self.apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::All); - if self.runtime_permission_profile_override.is_some() { - let reviewer = self.config.approvals_reviewer; - let reviewers = &config.config_layer_stack.requirements().approvals_reviewer; - if let Err(error) = reviewers.can_set(&reviewer) { - return self.working_directory_error(format!("Approvals reviewer: {error}")); - } - config.approvals_reviewer = reviewer; + if let Err(error) = self.apply_runtime_policy_overrides(&mut config, scope) { + return self.working_directory_error(format!("{error:#}")); } let actual = config.permissions.approval_policy.value(); let approval = self .runtime_approval_policy_override + .filter(|value| { + scope == RuntimePolicyOverrideScope::All + || matches!(value, RuntimeApprovalPolicyOverride::Explicit(_)) + }) .map(RuntimeApprovalPolicyOverride::policy); - let profile = self.runtime_permission_profile_override.as_ref(); if approval.is_some_and(|p| actual != p.to_core()) || profile.is_some_and(|profile| !profile.matches_config(&config)) { @@ -272,58 +378,6 @@ impl App { Err(error) => return self.chat_widget.add_error_message(error), }; config.service_tier = self.chat_widget.configured_service_tier(); - let is_new_worktree = matches!( - managed_worktree.as_ref().map(|(_, _, mode, _)| mode), - Some(crate::app_event::ManagedWorktreeMode::New) - ); - let rollout = self.chat_widget.rollout_path(); - let has_rollout = rollout.as_deref().is_some_and(rollout_path_is_resumable); - let channels = &self.thread_event_channels; - if !has_rollout - && !is_new_worktree - && (!self.chat_widget.token_usage().is_zero() - || channels.get(&thread_id).is_some_and(|channel| { - channel.store.try_lock().map_or(/*default*/ true, |store| { - !store.turns.is_empty() - || store.buffer.iter().any(|event| { - matches!( - event, - ThreadBufferedEvent::Notification(notification) - if matches!(notification.as_ref(), - ServerNotification::TurnStarted(_) - | ServerNotification::TurnCompleted(_)) - ) - }) - }) - })) - { - return self.working_directory_error("Conversation history is not saved."); - } - let mut ids: HashSet<_> = channels - .keys() - .copied() - .filter(|id| !closed_agents.contains(id)) - .collect(); - ids.extend(open_agents); - let descendants = ids.iter().copied().filter(|id| *id != thread_id); - for tracked_id in std::iter::once(thread_id).chain(descendants) { - let request = ClientRequest::ThreadBackgroundTerminalsList { - request_id: app_server.next_request_id(), - params: ThreadBackgroundTerminalsListParams { - thread_id: tracked_id.to_string(), - cursor: None, - limit: Some(1), - }, - }; - let handle = app_server.request_handle(); - let result = handle.request_typed::(request).await; - if let Some(message) = super::managed_worktree_creation::background_terminals_blocker( - result, - &self.app_server_target, - ) { - return self.working_directory_error(message); - } - } if is_new_worktree { apply_managed_new_thread_defaults( &mut config, @@ -332,6 +386,46 @@ impl App { &self.harness_overrides, ); } + // Config reads and folder consent can overlap work from another client. + if !self + .backfill_loaded_subagent_threads(app_server) + .await + .completed + { + return self.working_directory_error("Cannot check agent status."); + } + ids.extend( + self.agent_navigation + .ordered_threads() + .iter() + .filter_map(|(id, agent)| (!agent.is_closed).then_some(*id)), + ); + tracked_ids = std::iter::once(thread_id) + .chain(ids.iter().copied().filter(|id| *id != thread_id)) + .collect(); + for id in &tracked_ids { + match app_server.thread_read(*id, /*include_turns*/ false).await { + Ok(thread) + if matches!( + thread.status, + codex_app_server_protocol::ThreadStatus::Active { .. } + ) => + { + return self.working_directory_error("Cannot change: a task is running."); + } + Ok(_) => {} + Err(error) => { + return self + .working_directory_error(format!("Cannot check task status: {error}")); + } + } + } + if let Some(message) = + check_background_terminals(app_server, &self.app_server_target, tracked_ids).await + { + return self.working_directory_error(message); + } + has_rollout |= rollout.as_deref().is_some_and(rollout_path_is_resumable); let preserve_history = has_rollout && !is_new_worktree; let transitioned = if preserve_history { app_server @@ -466,6 +560,25 @@ impl App { if let Err(error) = attach_widget(self, tui, started, lineage, message).await { return self.working_directory_error(format!("Could not restore session: {error}")); } + if matches!( + self.runtime_approval_policy_override, + Some(RuntimeApprovalPolicyOverride::Restored(_)) + ) { + self.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Restored( + self.config.permissions.approval_policy.value().into(), + )); + } + if self + .runtime_permission_profile_override + .as_ref() + .is_some_and(|profile| { + profile.turn_override == RuntimePermissionProfileTurnOverride::Preserve + }) + { + self.runtime_permission_profile_override = Some( + RuntimePermissionProfileOverride::from_restored_config(&self.config), + ); + } if let Some(error) = name_error { self.chat_widget.add_error_message(error); } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 01b3d347c733..f45e2b12640b 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -7,6 +7,10 @@ mod external_agent_config; pub(crate) mod fs; mod history; mod models; +pub(crate) mod provider_selection; +#[cfg(test)] +#[path = "app_server_session/provider_selection_tests.rs"] +mod provider_selection_tests; mod realtime; mod rollout_history; mod thread_list; @@ -318,6 +322,8 @@ pub(crate) struct AppServerSession { task_search_generation: Arc, remote_cwd_override: Option, thread_params_mode: ThreadParamsMode, + /// Explicit CLI provider selection, distinct from resolved configuration defaults. + pub(crate) model_provider_override: Option, history_support: ThreadHistorySupport, thread_settings_update_supported: bool, default_model: Option, @@ -353,13 +359,6 @@ impl ThreadParamsMode { Self::Remote => None, } } - - fn model_provider_from_config(self, config: &Config) -> Option { - match self { - Self::Embedded => Some(config.model_provider_id.clone()), - Self::Remote => None, - } - } } #[derive(Debug, Clone)] @@ -428,6 +427,7 @@ impl AppServerSession { task_search_generation: Arc::new(AtomicU64::new(0)), remote_cwd_override: None, thread_params_mode, + model_provider_override: None, history_support: ThreadHistorySupport::Paginated, thread_settings_update_supported: true, default_model: None, @@ -649,7 +649,7 @@ impl AppServerSession { .map(|model| model.model.clone()) }) .or_else(|| available_models.first().map(|model| model.model.clone())) - .wrap_err("model/list returned no models for TUI bootstrap")?; + .wrap_err("No models are available. Set `model` explicitly or check your model catalog configuration.")?; self.default_model = Some(default_model.clone()); self.available_models = available_models.clone(); @@ -767,6 +767,10 @@ impl AppServerSession { remote_cwd_override.or(self.remote_cwd_override.as_deref()), session_start_source, ); + params.model_provider = self + .model_provider_override + .clone() + .or(params.model_provider); if let Some(selected_profile) = selected_profile { params.runtime_workspace_roots = None; params.permissions = Some(selected_profile.profile_id.clone()); @@ -939,6 +943,13 @@ impl AppServerSession { self.remote_cwd_override.as_deref(), ) }; + params.model_provider = match config_source { + ForkConfigSource::Local => self + .model_provider_override + .clone() + .or(params.model_provider), + ForkConfigSource::Session => Some(config.model_provider_id.clone()), + }; if config_source == ForkConfigSource::Session { // Active-session config has already adopted the server's roots. Fork does // not restore saved roots when omitted, so preserve this explicit selection. @@ -1675,6 +1686,7 @@ pub(crate) async fn start_thread_with_request_handle( thread_params_mode: ThreadParamsMode, remote_cwd_override: Option, thread_tool_transport: ThreadToolTransport, + model_provider_override: Option, ) -> Result { let request_id = RequestId::String(format!("startup-thread-start-{}", Uuid::new_v4())); let mut params = thread_start_params_from_config( @@ -1683,6 +1695,7 @@ pub(crate) async fn start_thread_with_request_handle( remote_cwd_override.as_deref(), /*session_start_source*/ None, ); + params.model_provider = model_provider_override.or(params.model_provider); thread_tool_transport.configure(&mut params); let (response, _history_support, task_tools_available) = request_thread_start_with_history_fallback(&request_handle, request_id, params) @@ -1836,18 +1849,6 @@ fn config_request_overrides_from_config( .as_ref() .map(std::string::ToString::to_string), ); - insert( - "model_reasoning_summary", - config - .model_reasoning_summary - .map(|summary| summary.to_string()), - ); - insert( - "model_verbosity", - config - .model_verbosity - .map(|verbosity| verbosity.to_string()), - ); insert( "personality", personality_opt_out_only(config.personality).map(|personality| personality.to_string()), @@ -1856,6 +1857,23 @@ fn config_request_overrides_from_config( "web_search", Some(config.web_search_mode.value().to_string()), ); + // Only winning launch choices may replace server defaults or saved thread settings. + let origins = config.config_layer_stack.origins(); + let effective = config.config_layer_stack.effective_config(); + for key in ["model_reasoning_summary", "model_verbosity"] { + if origins.get(key).is_some_and(|origin| { + matches!( + origin.name, + ConfigLayerSource::SessionFlags + | ConfigLayerSource::User { + profile: Some(_), + .. + } + ) + }) { + overrides.insert(key.to_string(), serde_json::json!(effective[key])); + } + } if config.bypass_hook_trust { overrides.insert("bypass_hook_trust".to_string(), true.into()); } @@ -2032,7 +2050,7 @@ pub(crate) fn thread_start_params_from_config( .flatten(); ThreadStartParams { model: config.model.clone(), - model_provider: thread_params_mode.model_provider_from_config(config), + model_provider: provider_selection::explicit_provider(config), service_tier: service_tier_override_from_config(config), cwd: thread_cwd_from_config(config, thread_params_mode, remote_cwd_override), runtime_workspace_roots: thread_params_mode.workspace_roots_from_config(config), @@ -2061,6 +2079,7 @@ fn thread_resume_params_from_config( thread_params_mode: ThreadParamsMode, remote_cwd_override: Option<&std::path::Path>, model_settings: ResumeModelSettings, + permission_overrides: crate::resume_permissions::ResumePermissions, ) -> ThreadResumeParams { if model_settings == ResumeModelSettings::PreserveExistingThread { return ThreadResumeParams { @@ -2090,7 +2109,7 @@ fn thread_resume_params_from_config( let (model, model_provider) = match model_settings { ResumeModelSettings::OverrideFromCurrentConfig => ( config.model.clone(), - thread_params_mode.model_provider_from_config(&config), + provider_selection::explicit_provider(&config), ), ResumeModelSettings::RestoreFromThread | ResumeModelSettings::PreserveExistingThread => { (None, None) @@ -2113,14 +2132,20 @@ fn thread_resume_params_from_config( ), ..ThreadResumeParams::default() }; - if thread_params_mode == ThreadParamsMode::Remote { - // Resuming restores the server's saved permission settings, including named profiles. + if thread_params_mode == ThreadParamsMode::Remote || !permission_overrides.approval_policy { params.approval_policy = None; + } + if thread_params_mode == ThreadParamsMode::Remote || !permission_overrides.approvals_reviewer { params.approvals_reviewer = None; + } + if thread_params_mode == ThreadParamsMode::Remote || !permission_overrides.profile { params.sandbox = None; params.permissions = None; remove_permission_config_overrides(&mut params.config); } + if !permission_overrides.workspace_roots { + params.runtime_workspace_roots = None; + } params } @@ -2143,7 +2168,7 @@ fn thread_fork_params_from_config( ThreadForkParams { thread_id: thread_id.to_string(), model: config.model.clone(), - model_provider: thread_params_mode.model_provider_from_config(&config), + model_provider: provider_selection::explicit_provider(&config), service_tier: service_tier_override_from_config(&config), cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override), runtime_workspace_roots: thread_params_mode.workspace_roots_from_config(&config), @@ -2289,23 +2314,14 @@ async fn thread_session_state_from_thread_resume_response( response: &ThreadResumeResponse, local_settings: &LocalSettings, config: &Config, - thread_params_mode: ThreadParamsMode, + _thread_params_mode: ThreadParamsMode, ) -> Result { - let permission_profile = if matches!(thread_params_mode, ThreadParamsMode::Embedded) - && response.active_permission_profile.is_none() - { - PermissionProfile::from_legacy_sandbox_policy_for_cwd( - &response.sandbox.to_core(), - response.cwd.as_path(), - ) - } else { - display_permission_profile_from_thread_response( - &response.sandbox, - response.cwd.as_path(), - config, - thread_params_mode, - ) - }; + // The server owns restored rules. This legacy projection is for display only; + // normal turns do not send it back as an execution override. + let permission_profile = PermissionProfile::from_legacy_sandbox_policy_for_cwd( + &response.sandbox.to_core(), + response.cwd.as_path(), + ); let mut session = thread_session_state_from_thread_response( &response.thread.id, crate::windows_sandbox::host_from_environments(response.thread.environments.as_deref()), @@ -2580,6 +2596,25 @@ mod tests { Ok(()) } + #[tokio::test] + async fn bootstrap_empty_catalog_reports_configuration_error() -> Result<()> { + let codex_home = tempfile::tempdir()?; + let mut config = build_config(&codex_home).await; + config.model = None; + config.model_catalog = Some(codex_protocol::openai_models::ModelsResponse::default()); + let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?; + + let error = app_server + .bootstrap(&config) + .await + .err() + .expect("bootstrap requires a model"); + insta::assert_snapshot!(error.to_string(), @"No models are available. Set `model` explicitly or check your model catalog configuration."); + + app_server.shutdown().await?; + Ok(()) + } + fn rate_limit_snapshot(limit_id: &str) -> RateLimitSnapshot { RateLimitSnapshot { limit_id: Some(limit_id.to_string()), @@ -2912,7 +2947,7 @@ mod tests { .active_permission_profile() .map(permission_profile_id_from_active_profile) ); - assert_eq!(params.model_provider, Some(config.model_provider_id)); + assert_eq!(params.model_provider, None); assert_eq!(params.thread_source, Some(ThreadSource::User)); assert_eq!(params.dynamic_tools, None); } @@ -3151,6 +3186,7 @@ mod tests { ThreadParamsMode::Remote, /*remote_cwd_override*/ None, ResumeModelSettings::OverrideFromCurrentConfig, + crate::resume_permissions::ResumePermissions::CURRENT_CONFIG, ); let fork = thread_fork_params_from_config( config, @@ -3196,6 +3232,7 @@ mod tests { ThreadParamsMode::Remote, Some(remote_cwd.as_path()), ResumeModelSettings::RestoreFromThread, + crate::resume_permissions::ResumePermissions::CURRENT_CONFIG, ); assert_eq!(resume.cwd, Some(remote_cwd.to_string_lossy().to_string())); @@ -3292,6 +3329,7 @@ mod tests { ThreadParamsMode::Remote, Some(remote_cwd.as_path()), ResumeModelSettings::OverrideFromCurrentConfig, + crate::resume_permissions::ResumePermissions::CURRENT_CONFIG, ); let fork = thread_fork_params_from_config( config, @@ -3347,6 +3385,7 @@ mod tests { ThreadParamsMode::Embedded, /*remote_cwd_override*/ None, ResumeModelSettings::OverrideFromCurrentConfig, + crate::resume_permissions::ResumePermissions::CURRENT_CONFIG, ); let fork = thread_fork_params_from_config( config, @@ -3362,12 +3401,11 @@ mod tests { let string = |value: &str| serde_json::Value::String(value.to_string()); let expected_config = HashMap::from([ ("model_reasoning_effort".to_string(), string("high")), - ("model_reasoning_summary".to_string(), string("detailed")), - ("model_verbosity".to_string(), string("low")), ("web_search".to_string(), string("disabled")), ("bypass_hook_trust".to_string(), true.into()), ]); let mut expected_start_config = expected_config.clone(); + expected_start_config.insert("model_reasoning_summary".to_string(), string("detailed")); expected_start_config.insert( "features".to_string(), serde_json::json!({"concurrent_reasoning_summaries": false}), @@ -3377,6 +3415,71 @@ mod tests { assert_eq!(fork.config, Some(expected_config)); } + #[tokio::test] + async fn config_overrides_forward_explicit_summary_and_verbosity() -> Result<()> { + let home = tempfile::tempdir()?; + let workspace = home.path().join("workspace"); + std::fs::create_dir_all(workspace.join(".codex"))?; + std::fs::write( + home.path().join("config.toml"), + format!( + "model_reasoning_summary = \"concise\"\nmodel_verbosity = \"low\"\n[projects.{}]\ntrust_level = \"trusted\"\n", + toml::Value::String(workspace.to_string_lossy().into_owned()), + ), + )?; + let profile = AbsolutePathBuf::from_absolute_path(home.path().join("work.config.toml"))?; + std::fs::write( + &profile, + "model_reasoning_summary = \"detailed\"\nmodel_verbosity = \"high\"\n", + )?; + for (selected_profile, project, cli, expected) in [ + (false, false, false, [None, None]), + (true, false, false, [Some("detailed"), Some("high")]), + (false, false, true, [Some("auto"), Some("medium")]), + (true, false, true, [Some("auto"), Some("medium")]), + (true, true, false, [None, None]), + (true, true, true, [Some("auto"), Some("medium")]), + ] { + std::fs::write( + workspace.join(".codex/config.toml"), + if project { + "model_reasoning_summary = \"concise\"\nmodel_verbosity = \"low\"\n" + } else { + "" + }, + )?; + let config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(workspace.clone()), + ..ConfigOverrides::default() + }) + .loader_overrides(codex_config::LoaderOverrides { + user_config_path: selected_profile.then(|| profile.clone()), + user_config_profile: selected_profile.then(|| "work".parse().unwrap()), + ..codex_config::LoaderOverrides::without_managed_config_for_tests() + }) + .cli_overrides(if cli { + vec![ + ("model_reasoning_summary".to_string(), "auto".into()), + ("model_verbosity".to_string(), "medium".into()), + ] + } else { + Vec::new() + }) + .build() + .await?; + let overrides = + config_request_overrides_from_config(&config).expect("config overrides"); + assert_eq!( + ["model_reasoning_summary", "model_verbosity"] + .map(|key| overrides.get(key).and_then(serde_json::Value::as_str)), + expected, + ); + } + Ok(()) + } + #[tokio::test] async fn thread_resume_params_can_restore_persisted_model_settings() { let temp_dir = tempfile::tempdir().expect("tempdir"); @@ -3393,22 +3496,17 @@ mod tests { ThreadParamsMode::Embedded, /*remote_cwd_override*/ None, ResumeModelSettings::RestoreFromThread, + crate::resume_permissions::ResumePermissions::CURRENT_CONFIG, ); assert_eq!(params.model, None); assert_eq!(params.model_provider, None); assert_eq!( params.config, - Some(HashMap::from([ - ( - "model_reasoning_summary".to_string(), - serde_json::Value::String("detailed".to_string()), - ), - ( - "web_search".to_string(), - serde_json::Value::String("cached".to_string()), - ), - ])) + Some(HashMap::from([( + "web_search".to_string(), + serde_json::Value::String("cached".to_string()), + )])) ); } @@ -3424,6 +3522,7 @@ mod tests { ThreadParamsMode::Embedded, /*remote_cwd_override*/ None, ResumeModelSettings::PreserveExistingThread, + crate::resume_permissions::ResumePermissions::CURRENT_CONFIG, ); assert_eq!( @@ -3742,6 +3841,7 @@ mod tests { mode, /*remote_cwd_override*/ None, ResumeModelSettings::OverrideFromCurrentConfig, + crate::resume_permissions::ResumePermissions::CURRENT_CONFIG, ); let fork = thread_fork_params_from_config( config.clone(), @@ -3853,6 +3953,7 @@ mod tests { ThreadParamsMode::Embedded, /*remote_cwd_override*/ None, ResumeModelSettings::OverrideFromCurrentConfig, + crate::resume_permissions::ResumePermissions::CURRENT_CONFIG, ); let control_fork = thread_fork_params_from_config( config.clone(), @@ -3883,6 +3984,7 @@ mod tests { ThreadParamsMode::Embedded, /*remote_cwd_override*/ None, ResumeModelSettings::OverrideFromCurrentConfig, + crate::resume_permissions::ResumePermissions::CURRENT_CONFIG, ); let treatment_fork = thread_fork_params_from_config( config, diff --git a/codex-rs/tui/src/app_server_session/prompt_history_tests.rs b/codex-rs/tui/src/app_server_session/prompt_history_tests.rs index 3969bc2ab87d..28092d3321d7 100644 --- a/codex-rs/tui/src/app_server_session/prompt_history_tests.rs +++ b/codex-rs/tui/src/app_server_session/prompt_history_tests.rs @@ -31,6 +31,12 @@ async fn lifecycle_metadata_uses_local_prompt_history() -> Result<()> { .codex_home(server_home.path().to_path_buf()) .build() .await?; + crate::legacy_core::config::set_project_trust_level( + server_home.path(), + &std::env::current_dir()?, + codex_protocol::config_types::TrustLevel::Trusted, + ) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?; config.history.persistence = HistoryPersistence::None; config.history.max_bytes = Some(1); let thread_id = ThreadId::from_string( @@ -64,6 +70,7 @@ async fn lifecycle_metadata_uses_local_prompt_history() -> Result<()> { mode, /*remote_cwd_override*/ None, app_server.thread_tool_transport(), + /*model_provider_override*/ None, ) .await?; assert_eq!(startup.session.message_history, expected); diff --git a/codex-rs/tui/src/app_server_session/provider_selection.rs b/codex-rs/tui/src/app_server_session/provider_selection.rs new file mode 100644 index 000000000000..e5a0c9082bad --- /dev/null +++ b/codex-rs/tui/src/app_server_session/provider_selection.rs @@ -0,0 +1,66 @@ +//! Provider request overrides honor managed requirements over explicit invocation choices. +//! Omission lets the server resolve the provider for thread creation, forks, and history lookup. + +use super::AppServerSession; +use crate::legacy_core::config::Config; +use codex_config::ConfigLayerSource; + +pub(crate) fn explicit_provider(config: &Config) -> Option { + config + .config_layer_stack + .layers_high_to_low() + .find(|layer| layer.config.get("model_provider").is_some()) + .filter(|layer| { + matches!( + layer.name, + ConfigLayerSource::SessionFlags + | ConfigLayerSource::User { + profile: Some(_), + .. + } + ) + }) + .map(|_| config.model_provider_id.clone()) +} + +impl AppServerSession { + pub(crate) fn explicit_model_provider(&self, config: &Config) -> Option { + self.model_provider_override + .as_ref() + .map(|provider| { + config + .config_layer_stack + .required_model_provider() + .unwrap_or(provider) + .to_owned() + }) + .or_else(|| explicit_provider(config)) + } + + pub(crate) async fn history_model_provider( + &self, + config: &Config, + ) -> color_eyre::Result> { + if let Some(provider) = self.explicit_model_provider(config) { + return Ok(Some(provider)); + } + let Some(cwd) = super::thread_cwd_from_config( + config, + self.thread_params_mode(), + self.remote_cwd_override(), + ) else { + return Ok(None); + }; + Ok(crate::config_update::read_effective_config_if_supported( + self.request_handle(), + std::path::Path::new(&cwd), + ) + .await? + .map(|config| { + config + .config + .model_provider + .unwrap_or_else(|| "openai".to_string()) + })) + } +} diff --git a/codex-rs/tui/src/app_server_session/provider_selection_tests.rs b/codex-rs/tui/src/app_server_session/provider_selection_tests.rs new file mode 100644 index 000000000000..5cd0235f83ef --- /dev/null +++ b/codex-rs/tui/src/app_server_session/provider_selection_tests.rs @@ -0,0 +1,173 @@ +//! Exercise provider defaults and explicit choices through real app-server requests. + +use super::*; +use crate::legacy_core::config::ConfigBuilder; +use crate::legacy_core::config::ConfigOverrides; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn starts_and_forks_use_server_provider_unless_explicitly_selected() -> Result<()> { + let home = tempfile::tempdir()?; + std::fs::write( + home.path().join("config.toml"), + r#" +model_provider = "server-provider" +model = "gpt-5.2" +[model_providers.server-provider] +name = "Server provider" +base_url = "http://127.0.0.1:9/v1" +wire_api = "responses" +requires_openai_auth = false +[model_providers.client-provider] +name = "Client provider" +base_url = "http://127.0.0.1:9/v1" +wire_api = "responses" +requires_openai_auth = false +"#, + )?; + let server_config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .build() + .await?; + let client_config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + model_provider: Some("client-provider".into()), + ..Default::default() + }) + .build() + .await?; + let local_settings = LocalSettings::from(&client_config); + let parent = crate::tests::write_session_rollout( + home.path(), + "2025-01-02T10-00-00", + "2025-01-02T10:00:00Z", + "Server history", + "server-provider", + server_config.cwd.as_path(), + )?; + let mut server = crate::start_embedded_app_server_for_picker(&server_config).await?; + assert_eq!( + crate::lookup_session_target_with_app_server(&mut server, &client_config, "Server history") + .await? + .unwrap() + .thread_id, + parent + ); + let resumed = server + .resume_thread( + &local_settings, + client_config.clone(), + parent, + ResumeModelSettings::OverrideFromCurrentConfig, + ) + .await?; + assert_eq!(resumed.session.model_provider_id, "server-provider"); + for explicit in [false, true] { + server.model_provider_override = explicit.then(|| "client-provider".to_string()); + let expected = if explicit { + "client-provider" + } else { + "server-provider" + }; + let started = server.start_thread(&client_config).await?; + let forked = server + .fork_thread(&local_settings, client_config.clone(), parent) + .await?; + assert_eq!( + ( + started.session.model_provider_id.as_str(), + forked.session.model_provider_id.as_str() + ), + (expected, expected) + ); + } + server.model_provider_override = None; + let path = home.path().join("config.toml"); + let updated = std::fs::read_to_string(&path)?.replacen( + "model_provider = \"server-provider\"", + "model_provider = \"client-provider\"", + 1, + ); + std::fs::write(path, updated)?; + assert_eq!( + server.history_model_provider(&client_config).await?, + Some("client-provider".into()) + ); + assert_eq!( + server + .start_thread(&client_config) + .await? + .session + .model_provider_id, + "client-provider" + ); + // A fork of the active session preserves its provider even if launch defaults differ. + server.model_provider_override = Some("client-provider".into()); + let forked = server + .fork_thread_at( + &local_settings, + server_config, + parent, + /*last_turn_id*/ None, + /*before_turn_id*/ None, + ForkGoalContinuation::StartIfIdle, + /*selected_profile*/ None, + ) + .await?; + assert_eq!(forked.session.model_provider_id, "server-provider"); + server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn selected_profile_provider_is_explicit() -> Result<()> { + let home = tempfile::tempdir()?; + let profile_path = AbsolutePathBuf::try_from(home.path().join("selected.config.toml"))?; + std::fs::write(&profile_path, "model_provider = \"openai\"\n")?; + let config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .loader_overrides(codex_config::LoaderOverrides { + user_config_path: Some(profile_path), + user_config_profile: Some("selected".parse()?), + ..codex_config::LoaderOverrides::without_managed_config_for_tests() + }) + .build() + .await?; + assert_eq!( + provider_selection::explicit_provider(&config), + Some("openai".into()) + ); + Ok(()) +} + +#[tokio::test] +async fn required_provider_overrides_oss_history_selection() -> Result<()> { + let home = tempfile::tempdir()?; + std::fs::write( + home.path().join("requirements.toml"), + "model_provider = 'openai'", + )?; + let config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .loader_overrides( + codex_config::LoaderOverrides::with_managed_config_path_for_tests( + home.path().join("managed_config.toml"), + ), + ) + .harness_overrides(ConfigOverrides { + model_provider: Some("ollama".into()), + ..Default::default() + }) + .build() + .await?; + let mut server = crate::start_embedded_app_server_for_picker(&config).await?; + server.model_provider_override = Some("ollama".into()); + assert_eq!(config.model_provider_id, "openai"); + assert_eq!( + server.history_model_provider(&config).await?, + Some("openai".into()) + ); + server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/app_server_session/reasoning_defaults_tests.rs b/codex-rs/tui/src/app_server_session/reasoning_defaults_tests.rs index 189dfce2bd7b..3cb3cbdfba42 100644 --- a/codex-rs/tui/src/app_server_session/reasoning_defaults_tests.rs +++ b/codex-rs/tui/src/app_server_session/reasoning_defaults_tests.rs @@ -1,58 +1,63 @@ -//! Verify embedded TUI reasoning configuration and metadata on its outbound model request. +//! Verify TUI reasoning defaults and server-owned settings on outbound model requests. use super::*; use crate::legacy_core::config::ConfigBuilder; use codex_app_server_protocol::ServerNotification; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::Verbosity; use core_test_support::responses; use pretty_assertions::assert_eq; use serde_json::json; #[tokio::test] -async fn embedded_reasoning_defaults_reach_responses() -> Result<()> { - for (settings, summary, stream_options) in [ - ("", json!(null), json!(null)), +async fn reasoning_defaults_reach_responses() -> Result<()> { + for (settings, summary, stream_options, mode) in [ + ("", json!(null), json!(null), ThreadParamsMode::Embedded), ( "[features]\nconcurrent_reasoning_summaries = false", json!(null), json!(null), + ThreadParamsMode::Embedded, ), ( "[features]\nconcurrent_reasoning_summaries = true", json!(null), json!(null), + ThreadParamsMode::Embedded, ), ( "model_reasoning_summary = 'detailed'", json!("detailed"), json!(null), + ThreadParamsMode::Embedded, ), ( "model_reasoning_summary = 'detailed'\n[features]\nconcurrent_reasoning_summaries = true", json!("detailed"), json!({"reasoning_summary_delivery": "sequential_cutoff"}), + ThreadParamsMode::Embedded, ), ( "model_reasoning_summary = 'none'\n[features]\nconcurrent_reasoning_summaries = true", json!(null), json!(null), + ThreadParamsMode::Embedded, + ), + ( + "model_reasoning_summary = 'detailed'\nmodel_verbosity = 'high'", + json!("detailed"), + json!(null), + ThreadParamsMode::Remote, ), ] { let server = responses::start_mock_server().await; - let response = responses::mount_sse_once( - &server, - responses::sse(vec![ - responses::ev_response_created("response"), - responses::ev_completed("response"), - ]), - ) - .await; let home = tempfile::tempdir()?; let base_url = server.uri(); std::fs::write( home.path().join("config.toml"), format!( r#" -model = "gpt-5.2" +model = "gpt-5.5" model_provider = "reasoning-test" {settings} [model_providers.reasoning-test] @@ -64,72 +69,112 @@ stream_max_retries = 0 "# ), )?; - let config = ConfigBuilder::default() + crate::legacy_core::config::set_project_trust_level( + home.path(), + &std::env::current_dir()?, + codex_protocol::config_types::TrustLevel::Trusted, + ) + .map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?; + let mut config = ConfigBuilder::default() .codex_home(home.path().to_path_buf()) .build() .await?; let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?; + app_server.thread_params_mode = mode; + if mode == ThreadParamsMode::Remote { + config.model_reasoning_summary = Some(ReasoningSummary::Concise); + config.model_verbosity = Some(Verbosity::Low); + } let started = app_server.start_thread(&config).await?; - let turn = app_server - .turn_start( - started.session.thread_id, - "tui-user-message".to_string(), - vec![UserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }], - config.cwd.to_path_buf(), - /*approval_policy*/ None, - /*approvals_reviewer*/ None, - TurnPermissionsOverride::Preserve, - &config.workspace_roots, - started.session.model.clone(), - /*effort*/ None, - /*summary*/ None, - /*service_tier*/ None, - /*collaboration_mode*/ None, - /*personality*/ None, - /*output_schema*/ None, - ) - .await?; - tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 30), async { - while let Some(event) = app_server.next_event().await { - if let AppServerEvent::ServerNotification(notification) = event - && let ServerNotification::TurnCompleted(completed) = *notification - { - assert_eq!( - completed.turn.status, - codex_app_server_protocol::TurnStatus::Completed - ); - return; + for resume in [false, true] { + if resume { + if mode != ThreadParamsMode::Remote { + break; } + app_server.shutdown().await?; + app_server = crate::start_embedded_app_server_for_picker(&config).await?; + app_server.thread_params_mode = mode; + app_server + .resume_thread( + &crate::local_settings::LocalSettings::from(&config), + config.clone(), + started.session.thread_id, + ResumeModelSettings::RestoreFromThread, + ) + .await?; } - panic!("app-server disconnected before completing the turn"); - }) - .await?; - let body = response.single_request().body_json(); - assert_eq!( - json!({"summary": body["reasoning"]["summary"], "stream_options": body["stream_options"]}), - json!({"summary": summary, "stream_options": stream_options}), - "settings: {settings}" - ); - let metadata: serde_json::Value = serde_json::from_str( - body["client_metadata"]["x-codex-turn-metadata"] - .as_str() - .expect("canonical turn metadata"), - )?; - assert_eq!( - ( - &metadata["thread_id"], - &metadata["turn_id"], - &metadata["turn_trigger"], - ), - ( - &json!(started.session.thread_id), - &json!(turn.turn.id), - &json!("user"), + let response = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("response"), + responses::ev_completed("response"), + ]), ) - ); + .await; + let turn = app_server + .turn_start( + started.session.thread_id, + "tui-user-message".to_string(), + vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + config.cwd.to_path_buf(), + /*approval_policy*/ None, + /*approvals_reviewer*/ None, + TurnPermissionsOverride::Preserve, + &config.workspace_roots, + started.session.model.clone(), + /*effort*/ None, + /*summary*/ None, + /*service_tier*/ None, + /*collaboration_mode*/ None, + /*personality*/ None, + /*output_schema*/ None, + ) + .await?; + tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 30), async { + while let Some(event) = app_server.next_event().await { + if let AppServerEvent::ServerNotification(notification) = event + && let ServerNotification::TurnCompleted(completed) = *notification + { + assert_eq!( + completed.turn.status, + codex_app_server_protocol::TurnStatus::Completed + ); + return; + } + } + panic!("app-server disconnected before completing the turn"); + }) + .await?; + let body = response.single_request().body_json(); + assert_eq!( + json!({"summary": body["reasoning"]["summary"], "stream_options": body["stream_options"]}), + json!({"summary": summary, "stream_options": stream_options}), + "settings: {settings}" + ); + if mode == ThreadParamsMode::Remote { + assert_eq!(body["text"]["verbosity"], json!("high")); + } + let metadata: serde_json::Value = serde_json::from_str( + body["client_metadata"]["x-codex-turn-metadata"] + .as_str() + .expect("canonical turn metadata"), + )?; + assert_eq!( + ( + &metadata["thread_id"], + &metadata["turn_id"], + &metadata["turn_trigger"], + ), + ( + &json!(started.session.thread_id), + &json!(turn.turn.id), + &json!("user"), + ) + ); + } app_server.shutdown().await?; } Ok(()) diff --git a/codex-rs/tui/src/app_server_session/rollout_history.rs b/codex-rs/tui/src/app_server_session/rollout_history.rs index 5a86ee026089..f16428421ad0 100644 --- a/codex-rs/tui/src/app_server_session/rollout_history.rs +++ b/codex-rs/tui/src/app_server_session/rollout_history.rs @@ -137,6 +137,24 @@ impl AppServerSession { config: Config, thread_id: ThreadId, model_settings: ResumeModelSettings, + ) -> Result { + Box::pin(self.resume_thread_with_permission_overrides( + local_settings, + config, + thread_id, + model_settings, + crate::resume_permissions::ResumePermissions::default(), + )) + .await + } + + pub(crate) async fn resume_thread_with_permission_overrides( + &mut self, + local_settings: &crate::local_settings::LocalSettings, + config: Config, + thread_id: ThreadId, + model_settings: ResumeModelSettings, + permission_overrides: crate::resume_permissions::ResumePermissions, ) -> Result { let session_config = if matches!( model_settings, @@ -152,7 +170,11 @@ impl AppServerSession { self.thread_params_mode(), self.remote_cwd_override.as_deref(), model_settings, + permission_overrides, ); + if model_settings == ResumeModelSettings::OverrideFromCurrentConfig { + params.model_provider = self.explicit_model_provider(&config); + } self.thread_tool_transport() .configure_mcp(&mut params.config); let mut rollout_maintenance_guard = None; diff --git a/codex-rs/tui/src/app_server_session/rollout_history_tests.rs b/codex-rs/tui/src/app_server_session/rollout_history_tests.rs index c08e9407cb9f..0a6cd18c9279 100644 --- a/codex-rs/tui/src/app_server_session/rollout_history_tests.rs +++ b/codex-rs/tui/src/app_server_session/rollout_history_tests.rs @@ -103,30 +103,33 @@ async fn remote_resume_restores_saved_server_profile_without_permission_override let mut server = crate::start_embedded_app_server_for_picker(&config).await?; server.thread_params_mode = ThreadParamsMode::Remote; - let resumed = server - .resume_thread( - &local_settings, - client_config.clone(), - thread_id, - ResumeModelSettings::RestoreFromThread, - ) - .await?; - assert_eq!( - resumed - .session - .active_permission_profile - .as_ref() - .map(|profile| profile.id.as_str()), - Some("server-only") - ); - assert_eq!( - resumed.session.approval_policy, - codex_app_server_protocol::AskForApproval::Never - ); - assert_eq!( - resumed.session.approvals_reviewer, - codex_protocol::config_types::ApprovalsReviewer::AutoReview - ); + for mode in [ThreadParamsMode::Embedded, ThreadParamsMode::Remote] { + server.thread_params_mode = mode; + let resumed = server + .resume_thread_with_permission_overrides( + &local_settings, + client_config.clone(), + thread_id, + ResumeModelSettings::RestoreFromThread, + crate::resume_permissions::ResumePermissions::default(), + ) + .await?; + assert_eq!( + ( + resumed + .session + .active_permission_profile + .map(|profile| profile.id), + resumed.session.approval_policy, + resumed.session.approvals_reviewer, + ), + ( + Some("server-only".to_string()), + codex_app_server_protocol::AskForApproval::Never, + codex_protocol::config_types::ApprovalsReviewer::AutoReview, + ), + ); + } // A locally remembered profile may have been removed since selection. let stale_selection = crate::app_event::PermissionProfileSelection { profile_id: "removed-profile".into(), @@ -194,6 +197,42 @@ async fn remote_resume_restores_saved_server_profile_without_permission_override codex_protocol::models::PermissionProfile::read_only() ); server.shutdown().await?; + let extra = tempfile::tempdir()?; + let mut overrides = crate::legacy_core::config::ConfigOverrides { + default_permissions: Some(":workspace".into()), + additional_writable_roots: vec![extra.path().to_path_buf()], + ..Default::default() + }; + let config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .harness_overrides(overrides.clone()) + .build() + .await?; + let mut server = crate::start_embedded_app_server_for_picker(&config).await?; + overrides.default_permissions = None; // Exercise --add-dir without a profile flag. + let permissions = + crate::resume_permissions::ResumePermissions::from_overrides(&config, &overrides); + let resumed = server + .resume_thread_with_permission_overrides( + &local_settings, + config.clone(), + thread_id, + ResumeModelSettings::RestoreFromThread, + permissions, + ) + .await?; + assert_eq!( + resumed.session.runtime_workspace_roots, + config.workspace_roots + ); + assert!( + resumed + .session + .permission_profile + .file_system_sandbox_policy() + .can_write_local_path_with_cwd(extra.path(), config.cwd.as_path()) + ); + server.shutdown().await?; Ok(()) } diff --git a/codex-rs/tui/src/app_server_session/workspace_roots_tests.rs b/codex-rs/tui/src/app_server_session/workspace_roots_tests.rs index 29e8611d800a..ee2dcef069d8 100644 --- a/codex-rs/tui/src/app_server_session/workspace_roots_tests.rs +++ b/codex-rs/tui/src/app_server_session/workspace_roots_tests.rs @@ -188,6 +188,7 @@ async fn embedded_lifecycle_requests_preserve_explicit_workspace_roots() -> Resu ThreadParamsMode::Embedded, /*remote_cwd_override*/ None, ResumeModelSettings::RestoreFromThread, + crate::resume_permissions::ResumePermissions::CURRENT_CONFIG, ); let fork = thread_fork_params_from_config( config, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__shortcut_help_hides_visible_sparkles_without_restarting_their_deadline.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__shortcut_help_hides_visible_sparkles_without_restarting_their_deadline.snap index 3016ed71899e..79185244d18e 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__shortcut_help_hides_visible_sparkles_without_restarting_their_deadline.snap +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__shortcut_help_hides_visible_sparkles_without_restarting_their_deadline.snap @@ -13,18 +13,18 @@ shortcut help: Compose Session / Commands tab Send message - @ Mention files ⌥+, Less reasoning - ! Shell command ⌥+. More reasoning + @ Mention files ⌥, Less reasoning + ! Shell command ⌥. More reasoning ctrl+j New line f4 Inspect activity ctrl+v Paste image ctrl+c Quit ctrl+g External editor ctrl+r Search history Transcript (open first) - esc esc Edit last message ctrl+t Open transcript - f3 Find text - pgup / pgdn Scroll - ctrl+space Start selection - ctrl+home / ctrl+end Top / latest - shift+⌥+, / shift+⌥+. Top / latest + esc esc Edit last message ctrl+t Open transcript + f3 Find text + pgup / pgdn Scroll + ctrl+space Start selection + ctrl+home / ctrl+end Top / latest + shift+⌥, / shift+⌥. Top / latest /keymap customize @@ -48,18 +48,18 @@ shortcut help: Compose Session / Commands tab Send message - @ Mention files ⌥+, Less reasoning - ! Shell command ⌥+. More reasoning + @ Mention files ⌥, Less reasoning + ! Shell command ⌥. More reasoning ctrl+j New line f4 Inspect activity ctrl+v Paste image ctrl+c Quit ctrl+g External editor ctrl+r Search history Transcript (open first) - esc esc Edit last message ctrl+t Open transcript - f3 Find text - pgup / pgdn Scroll - ctrl+space Start selection - ctrl+home / ctrl+end Top / latest - shift+⌥+, / shift+⌥+. Top / latest + esc esc Edit last message ctrl+t Open transcript + f3 Find text + pgup / pgdn Scroll + ctrl+space Start selection + ctrl+home / ctrl+end Top / latest + shift+⌥, / shift+⌥. Top / latest /keymap customize diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__shortcut_help_preserves_an_unused_sparkle_but_literal_question_marks_do_not.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__shortcut_help_preserves_an_unused_sparkle_but_literal_question_marks_do_not.snap index bcebcd4bb5b8..0e43b92df458 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__shortcut_help_preserves_an_unused_sparkle_but_literal_question_marks_do_not.snap +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__sparkle__tests__shortcut_help_preserves_an_unused_sparkle_but_literal_question_marks_do_not.snap @@ -7,18 +7,18 @@ shortcut help: Compose Session / Commands tab Send message - @ Mention files ⌥+, Less reasoning - ! Shell command ⌥+. More reasoning + @ Mention files ⌥, Less reasoning + ! Shell command ⌥. More reasoning ctrl+j New line f4 Inspect activity ctrl+v Paste image ctrl+c Quit ctrl+g External editor ctrl+r Search history Transcript (open first) - esc esc Edit last message ctrl+t Open transcript - f3 Find text - pgup / pgdn Scroll - ctrl+space Start selection - ctrl+home / ctrl+end Top / latest - shift+⌥+, / shift+⌥+. Top / latest + esc esc Edit last message ctrl+t Open transcript + f3 Find text + pgup / pgdn Scroll + ctrl+space Start selection + ctrl+home / ctrl+end Top / latest + shift+⌥, / shift+⌥. Top / latest /keymap customize diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__activity_shortcut_help_follows_runtime_binding.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__activity_shortcut_help_follows_runtime_binding.snap index 1d3d9c831101..bdd1b215502f 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__activity_shortcut_help_follows_runtime_binding.snap +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__activity_shortcut_help_follows_runtime_binding.snap @@ -14,12 +14,12 @@ expression: "snapshots.join(\"\\n---\\n\")" Keyboard shortcuts Compose Session Transcript (open first) - / Commands tab Send message ctrl+t Open transcript - @ Mention files ⌥+, Less reasoning f3 Find text - ! Shell command ⌥+. More reasoning pgup / pgdn Scroll - ctrl+j New line f4 Inspect activity ctrl+space Start selection - ctrl+v Paste image ctrl+c Quit ctrl+home / ctrl+end Top / latest - ctrl+g External editor shift+⌥+, / shift+⌥+. Top / latest + / Commands tab Send message ctrl+t Open transcript + @ Mention files ⌥, Less reasoning f3 Find text + ! Shell command ⌥. More reasoning pgup / pgdn Scroll + ctrl+j New line f4 Inspect activity ctrl+space Start selection + ctrl+v Paste image ctrl+c Quit ctrl+home / ctrl+end Top / latest + ctrl+g External editor shift+⌥, / shift+⌥. Top / latest ctrl+r Search history esc esc Edit last message @@ -42,12 +42,12 @@ expression: "snapshots.join(\"\\n---\\n\")" Keyboard shortcuts Compose Session Transcript (open first) - / Commands tab Send message ctrl+t Open transcript - @ Mention files ⌥+, Less reasoning f3 Find text - ! Shell command ⌥+. More reasoning pgup / pgdn Scroll - ctrl+j New line ctrl+x t Inspect activity ctrl+space Start selection - ctrl+v Paste image ctrl+c Quit ctrl+home / ctrl+end Top / latest - ctrl+g External editor shift+⌥+, / shift+⌥+. Top / latest + / Commands tab Send message ctrl+t Open transcript + @ Mention files ⌥, Less reasoning f3 Find text + ! Shell command ⌥. More reasoning pgup / pgdn Scroll + ctrl+j New line ctrl+x t Inspect activity ctrl+space Start selection + ctrl+v Paste image ctrl+c Quit ctrl+home / ctrl+end Top / latest + ctrl+g External editor shift+⌥, / shift+⌥. Top / latest ctrl+r Search history esc esc Edit last message @@ -70,12 +70,12 @@ expression: "snapshots.join(\"\\n---\\n\")" Keyboard shortcuts Compose Session Transcript (open first) - / Commands tab Send message ctrl+t Open transcript - @ Mention files ⌥+, Less reasoning f3 Find text - ! Shell command ⌥+. More reasoning pgup / pgdn Scroll - ctrl+j New line ctrl+c Quit ctrl+space Start selection - ctrl+v Paste image ctrl+home / ctrl+end Top / latest - ctrl+g External editor shift+⌥+, / shift+⌥+. Top / latest + / Commands tab Send message ctrl+t Open transcript + @ Mention files ⌥, Less reasoning f3 Find text + ! Shell command ⌥. More reasoning pgup / pgdn Scroll + ctrl+j New line ctrl+c Quit ctrl+space Start selection + ctrl+v Paste image ctrl+home / ctrl+end Top / latest + ctrl+g External editor shift+⌥, / shift+⌥. Top / latest ctrl+r Search history esc esc Edit last message diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__shortcut_help_above_narrow.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__shortcut_help_above_narrow.snap index 84f7c1420414..cb5cc69d5e6d 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__shortcut_help_above_narrow.snap +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__shortcut_help_above_narrow.snap @@ -24,18 +24,18 @@ expression: text Session tab Send message - ⌥+, Less reasoning - ⌥+. More reasoning + ⌥, Less reasoning + ⌥. More reasoning f4 Inspect activity ctrl+c Quit Transcript (open first) - ctrl+t Open transcript - f3 Find text - pgup / pgdn Scroll - ctrl+space Start selection - ctrl+home / ctrl+end Top / latest - shift+⌥+, / shift+⌥+. Top / latest + ctrl+t Open transcript + f3 Find text + pgup / pgdn Scroll + ctrl+space Start selection + ctrl+home / ctrl+end Top / latest + shift+⌥, / shift+⌥. Top / latest /keymap customize diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__shortcut_help_above_wide.snap b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__shortcut_help_above_wide.snap index 304ad3ed397f..e7493456d09b 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__shortcut_help_above_wide.snap +++ b/codex-rs/tui/src/bottom_pane/chat_composer/snapshots/codex_tui__bottom_pane__chat_composer__status_surface__tests__shortcut_help_above_wide.snap @@ -13,12 +13,12 @@ expression: text Keyboard shortcuts Compose Session Transcript (open first) - / Commands tab Send message ctrl+t Open transcript - @ Mention files ⌥+, Less reasoning f3 Find text - ! Shell command ⌥+. More reasoning pgup / pgdn Scroll - ctrl+j New line f4 Inspect activity ctrl+space Start selection - ctrl+v Paste image ctrl+c Quit ctrl+home / ctrl+end Top / latest - ctrl+g External editor shift+⌥+, / shift+⌥+. Top / latest + / Commands tab Send message ctrl+t Open transcript + @ Mention files ⌥, Less reasoning f3 Find text + ! Shell command ⌥. More reasoning pgup / pgdn Scroll + ctrl+j New line f4 Inspect activity ctrl+space Start selection + ctrl+v Paste image ctrl+c Quit ctrl+home / ctrl+end Top / latest + ctrl+g External editor shift+⌥, / shift+⌥. Top / latest ctrl+r Search history esc esc Edit last message diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__agents_navigation_help.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__agents_navigation_help.snap index 4fd8fbc9a106..5ebf53ced554 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__agents_navigation_help.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__agents_navigation_help.snap @@ -6,19 +6,19 @@ expression: terminal.backend() " " " Compose Session " " / Commands tab Send message " -" @ Mention files ⌥+, Less reasoning " -" ! Shell command ⌥+. More reasoning " +" @ Mention files ⌥, Less reasoning " +" ! Shell command ⌥. More reasoning " " ctrl+j New line ← Agents (empty prompt) " " ctrl+v Paste image f4 Inspect activity " " ctrl+g External editor ctrl+c Quit " " ctrl+r Search history " " esc esc Edit last message Transcript (open first) " -" ctrl+t Open transcript " -" f3 Find text " -" pgup / pgdn Scroll " -" ctrl+space Start selection " -" ctrl+home / ctrl+end Top / latest " -" shift+⌥+, / shift+⌥+. Top / latest " +" ctrl+t Open transcript " +" f3 Find text " +" pgup / pgdn Scroll " +" ctrl+space Start selection " +" ctrl+home / ctrl+end Top / latest " +" shift+⌥, / shift+⌥. Top / latest " " " " /keymap customize " " " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_configured_key_chords.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_configured_key_chords.snap index ac5dc30da6ad..bd11f949eda6 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_configured_key_chords.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_configured_key_chords.snap @@ -6,18 +6,18 @@ expression: terminal.backend() " " " Compose Session " " / Commands tab Send message " -" @ Mention files ⌥+, Less reasoning " -" ! Shell command ⌥+. More reasoning " +" @ Mention files ⌥, Less reasoning " +" ! Shell command ⌥. More reasoning " " ctrl+x enter New line f4 Inspect activity " " ctrl+v Paste image ctrl+c Quit " " ctrl+g ctrl+g External editor " " ctrl+r Search history Transcript (open first) " -" esc esc Edit last message ctrl+t Open transcript " -" f3 Find text " -" pgup / pgdn Scroll " -" ctrl+space Start selection " -" ctrl+home / ctrl+end Top / latest " -" shift+⌥+, / shift+⌥+. Top / latest " +" esc esc Edit last message ctrl+t Open transcript " +" f3 Find text " +" pgup / pgdn Scroll " +" ctrl+space Start selection " +" ctrl+home / ctrl+end Top / latest " +" shift+⌥, / shift+⌥. Top / latest " " " " /keymap customize " " " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap index 3b60d2cf4711..ad70ddb2795b 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -6,18 +6,18 @@ expression: terminal.backend() " " " Compose Session " " / Commands tab Send message " -" @ Mention files ⌥+, Less reasoning " -" ! Shell command ⌥+. More reasoning " +" @ Mention files ⌥, Less reasoning " +" ! Shell command ⌥. More reasoning " " shift+enter New line f4 Inspect activity " " ctrl+v Paste image ctrl+c Quit " " ctrl+g External editor " " ctrl+r Search history Transcript (open first) " -" esc Edit last message ctrl+t Open transcript " -" f3 Find text " -" pgup / pgdn Scroll " -" ctrl+space Start selection " -" ctrl+home / ctrl+end Top / latest " -" shift+⌥+, / shift+⌥+. Top / latest " +" esc Edit last message ctrl+t Open transcript " +" f3 Find text " +" pgup / pgdn Scroll " +" ctrl+space Start selection " +" ctrl+home / ctrl+end Top / latest " +" shift+⌥, / shift+⌥. Top / latest " " " " /keymap customize " " " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay_queue_submissions.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay_queue_submissions.snap index 2c3774ef6633..3729a7708f92 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay_queue_submissions.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay_queue_submissions.snap @@ -6,18 +6,18 @@ expression: terminal.backend() " " " Compose Session " " / Commands tab Queue message " -" @ Mention files ⌥+, Less reasoning " -" ! Shell command ⌥+. More reasoning " +" @ Mention files ⌥, Less reasoning " +" ! Shell command ⌥. More reasoning " " shift+enter New line f4 Inspect activity " " ctrl+v Paste image ctrl+c Quit " " ctrl+g External editor " " ctrl+r Search history Transcript (open first) " -" esc esc Edit last message ctrl+t Open transcript " -" f3 Find text " -" pgup / pgdn Scroll " -" ctrl+space Start selection " -" ctrl+home / ctrl+end Top / latest " -" shift+⌥+, / shift+⌥+. Top / latest " +" esc esc Edit last message ctrl+t Open transcript " +" f3 Find text " +" pgup / pgdn Scroll " +" ctrl+space Start selection " +" ctrl+home / ctrl+end Top / latest " +" shift+⌥, / shift+⌥. Top / latest " " " " /keymap customize " " " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap index 191f6faff5df..400c4dd0a46d 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_collaboration_modes_enabled.snap @@ -7,18 +7,18 @@ expression: terminal.backend() " Compose Session " " / Commands tab Send message " " @ Mention files shift+tab Change mode " -" ! Shell command ⌥+, Less reasoning " -" ctrl+j New line ⌥+. More reasoning " +" ! Shell command ⌥, Less reasoning " +" ctrl+j New line ⌥. More reasoning " " ctrl+v Paste image f8 Voice " " ctrl+g External editor f4 Inspect activity " " ctrl+r Search history ctrl+c Quit " " esc esc Edit last message " " Transcript (open first) " -" ctrl+t Open transcript " -" f3 Find text " -" pgup / pgdn Scroll " -" ctrl+space Start selection " -" ctrl+home / ctrl+end Top / latest " -" shift+⌥+, / shift+⌥+. Top / latest " +" ctrl+t Open transcript " +" f3 Find text " +" pgup / pgdn Scroll " +" ctrl+space Start selection " +" ctrl+home / ctrl+end Top / latest " +" shift+⌥, / shift+⌥. Top / latest " " " " /keymap customize " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_running.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_running.snap index 96ca5ec3f8c8..24675e60be2f 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_running.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_running.snap @@ -6,18 +6,18 @@ expression: terminal.backend() " " " Compose Session " " / Commands tab Queue message " -" @ Mention files ⌥+, Less reasoning " -" ! Shell command ⌥+. More reasoning " +" @ Mention files ⌥, Less reasoning " +" ! Shell command ⌥. More reasoning " " ctrl+j New line f8 Voice " " ctrl+v Paste image f4 Inspect activity " " ctrl+g External editor ctrl+c Interrupt " " ctrl+r Search history " " esc esc Edit last message Transcript (open first) " -" ctrl+t Open transcript " -" f3 Find text " -" pgup / pgdn Scroll " -" ctrl+space Start selection " -" ctrl+home / ctrl+end Top / latest " -" shift+⌥+, / shift+⌥+. Top / latest " +" ctrl+t Open transcript " +" f3 Find text " +" pgup / pgdn Scroll " +" ctrl+space Start selection " +" ctrl+home / ctrl+end Top / latest " +" shift+⌥, / shift+⌥. Top / latest " " " " /keymap customize " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap index 5ecba9d72814..8728b00f089d 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -6,18 +6,18 @@ expression: terminal.backend() " " " Compose Session " " / Commands tab Send message " -" @ Mention files ⌥+, Less reasoning " -" ! Shell command ⌥+. More reasoning " +" @ Mention files ⌥, Less reasoning " +" ! Shell command ⌥. More reasoning " " shift+enter New line f8 Voice " " ctrl+v Paste image f4 Inspect activity " " ctrl+g External editor ctrl+c Quit " " ctrl+r Search history " " esc Edit last message Transcript (open first) " -" ctrl+t Open transcript " -" f3 Find text " -" pgup / pgdn Scroll " -" ctrl+space Start selection " -" ctrl+home / ctrl+end Top / latest " -" shift+⌥+, / shift+⌥+. Top / latest " +" ctrl+t Open transcript " +" f3 Find text " +" pgup / pgdn Scroll " +" ctrl+space Start selection " +" ctrl+home / ctrl+end Top / latest " +" shift+⌥, / shift+⌥. Top / latest " " " " /keymap customize " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__shortcut_overlay__tests__shortcut_overlay_customized_wsl.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__shortcut_overlay__tests__shortcut_overlay_customized_wsl.snap index 44e3afb8febc..9cf874353189 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__shortcut_overlay__tests__shortcut_overlay_customized_wsl.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__shortcut_overlay__tests__shortcut_overlay_customized_wsl.snap @@ -7,18 +7,18 @@ expression: terminal.backend() " Compose Session " " / Commands tab Send message " " @ Mention files shift+tab Change mode " -" ! Shell command ⌥+. More reasoning " +" ! Shell command ⌥. More reasoning " " shift+enter New line f8 Voice " -" ctrl+⌥+v Paste image ← Agents (empty prompt) " +" ctrl+⌥v Paste image ← Agents (empty prompt) " " ctrl+x ctrl+e External editor f4 Inspect activity " " esc Edit last message ctrl+c Quit " " " " Transcript (open first) " -" ctrl+t Open transcript " -" f3 Find text " -" pgup / pgdn Scroll " -" ctrl+space Start selection " -" ctrl+home / ctrl+end Top / latest " -" shift+⌥+, / shift+⌥+. Top / latest " +" ctrl+t Open transcript " +" f3 Find text " +" pgup / pgdn Scroll " +" ctrl+space Start selection " +" ctrl+home / ctrl+end Top / latest " +" shift+⌥, / shift+⌥. Top / latest " " " " /keymap customize " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__projectless_windows_sandbox_setup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__projectless_windows_sandbox_setup.snap new file mode 100644 index 000000000000..54f5d94a25b1 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__projectless_windows_sandbox_setup.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/chatwidget/tests/permissions.rs +expression: popup +--- + Set up the Codex agent sandbox to protect your files and control network access. Learn more + + + +› 1. Set up default sandbox (requires Administrator permissions) + 2. Use non-admin sandbox (higher risk if prompt injected) + 3. Quit + + enter select · esc back diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index 68f7a1e263a7..b20599a43d9d 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -571,11 +571,36 @@ async fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() { ); } -#[cfg(target_os = "windows")] #[tokio::test] async fn startup_prompts_for_windows_sandbox_when_agent_requested() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let cwd = tempfile::tempdir().unwrap(); + chat.config = crate::legacy_core::config::ConfigBuilder::default() + .codex_home(chat.config.codex_home.to_path_buf()) + .loader_overrides(codex_config::LoaderOverrides::without_managed_config_for_tests()) + .harness_overrides(crate::legacy_core::config::ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }) + .build() + .await + .unwrap(); + assert!(chat.config.config_layer_stack.is_projectless()); + let approval = codex_protocol::protocol::AskForApproval::Granular( + codex_protocol::protocol::GranularApprovalConfig { + sandbox_approval: false, + rules: false, + skill_approval: false, + request_permissions: true, + mcp_elicitations: true, + }, + ); + chat.config + .permissions + .approval_policy + .set(approval) + .unwrap(); chat.set_windows_sandbox_mode(/*mode*/ None); chat.maybe_prompt_windows_sandbox_enable(/*show_now*/ true); @@ -597,6 +622,15 @@ async fn startup_prompts_for_windows_sandbox_when_agent_requested() { popup.contains("Quit"), "expected startup prompt to offer quit action: {popup}" ); + assert_chatwidget_snapshot!("projectless_windows_sandbox_setup", popup); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let preset = std::iter::from_fn(|| rx.try_recv().ok()) + .find_map(|event| match event { + AppEvent::BeginWindowsSandboxElevatedSetup { preset, .. } => Some(preset), + _ => None, + }) + .expect("sandbox setup event"); + assert_eq!(preset.approval, approval); } #[cfg(target_os = "windows")] diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_queue_hint_Up.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_queue_hint_Up.snap index 0a07dbe628f2..9e4b422a7ae0 100644 --- a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_queue_hint_Up.snap +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__questions_tests__question_queue_hint_Up.snap @@ -11,4 +11,4 @@ expression: "render_bottom_popup(&chat, 100)" Type your answer - enter submit ctrl+] skip ⌥+↓ main prompt ⌥+↑ next question + enter submit ctrl+] skip ⌥↓ main prompt ⌥↑ next question diff --git a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs index bb3d63988b40..0bcf5fbe1a62 100644 --- a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs +++ b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs @@ -272,23 +272,25 @@ impl ChatWidget { ) { } - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", test))] pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self, show_now: bool) { let setup_is_required = !self.windows_sandbox_config.is_enabled() || self.elevated_windows_sandbox_setup_required(); if show_now && setup_is_required - && let Some(preset) = builtin_approval_presets() + && let Some(mut preset) = builtin_approval_presets() .into_iter() .find(|preset| preset.id == "auto") { + if self.config.active_project.trust_level.is_none() + && self.config.config_layer_stack.is_projectless() + { + preset.approval = self.config.permissions.approval_policy.value(); + } self.open_windows_sandbox_enable_prompt(preset, /*profile_selection*/ None); } } - #[cfg(all(not(target_os = "windows"), test))] - pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self, _show_now: bool) {} - #[cfg(any(target_os = "windows", test))] pub(crate) fn show_windows_sandbox_setup_status(&mut self) { // While elevated sandbox setup runs, prevent typing so the user doesn't diff --git a/codex-rs/tui/src/config_update.rs b/codex-rs/tui/src/config_update.rs index 7a50e6dd0498..2595bac4c08a 100644 --- a/codex-rs/tui/src/config_update.rs +++ b/codex-rs/tui/src/config_update.rs @@ -19,7 +19,7 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SkillsConfigWriteParams; use codex_app_server_protocol::SkillsConfigWriteResponse; use codex_config::default_project_root_markers; -use codex_config::loader::find_project_root; +use codex_config::loader::discover_project_root; use codex_config::loader::normalized_project_trust_keys; use codex_config::loader::project_trust_key; use codex_exec_server::LOCAL_ENVIRONMENT_ID; @@ -199,7 +199,12 @@ pub(crate) async fn read_remote_project_trust( cwd: &Path, host: ProjectTrustHost, ) -> Result> { - let cwd_string = cwd.to_string_lossy().into_owned(); + let cwd_string = cwd + .to_str() + .ok_or_else(|| { + color_eyre::eyre::eyre!("Cannot check folder trust: directory path is not valid UTF-8") + })? + .to_owned(); let cwd = match LegacyAppPathString::from_string(cwd_string.clone()).to_inferred_path_uri() { Some(cwd) => cwd, None => { @@ -272,16 +277,21 @@ pub(crate) async fn read_remote_project_trust( .min_by_key(|(path, _)| *path) }) }; + let mut projectless = false; let local_roots = if host == ProjectTrustHost::Local && project_layers.is_empty() { let cwd = AbsolutePathBuf::from_absolute_path(&cwd)?; let markers = serde_json::from_value::>>( response["config"]["project_root_markers"].clone(), )? .unwrap_or_else(default_project_root_markers); - let project_root = find_project_root(LOCAL_FS.as_ref(), &cwd, &markers).await?; + let project_root = discover_project_root(LOCAL_FS.as_ref(), &cwd, &markers).await?; let git_root = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &cwd).await; + projectless = project_root.is_none() + && discover_project_root(LOCAL_FS.as_ref(), &cwd, &default_project_root_markers()) + .await? + .is_none(); Some(( - normalized_project_trust_keys(project_root.as_path()), + normalized_project_trust_keys(project_root.as_ref().unwrap_or(&cwd).as_path()), git_root.map(|root| normalized_project_trust_keys(root.as_path())), )) } else { @@ -345,6 +355,7 @@ pub(crate) async fn read_remote_project_trust( }); if !explicitly_untrusted && (trust_level == Some(TrustLevel::Trusted) + || trust_level.is_none() && projectless || (trust_level.is_none() && disabled_project.is_none() && project_layers @@ -405,13 +416,30 @@ pub(crate) async fn write_skill_enabled( #[path = "config_update_tests.rs"] mod tests; +/// Settings and layer kinds used by the TUI; ignore server-native paths and origins. +#[derive(serde::Deserialize)] +pub(crate) struct EffectiveConfig { + pub config: codex_app_server_protocol::Config, + pub layers: Option>, +} + /// Read effective server settings, retaining compatibility with servers without config/read. pub(crate) async fn read_effective_config_if_supported( request_handle: AppServerRequestHandle, cwd: &Path, -) -> Result> { - match read_effective_config(request_handle, cwd.display().to_string()).await { - Ok(response) => Ok(Some(response.config)), +) -> Result> { + match request_handle + .request_typed(ClientRequest::ConfigRead { + request_id: RequestId::String(format!("tui-config-read-{}", Uuid::new_v4())), + params: ConfigReadParams { + include_layers: true, + cwd: Some(cwd.display().to_string()), + }, + }) + .await + .wrap_err("config/read failed in TUI") + { + Ok(response) => Ok(Some(response)), Err(err) if matches!( err.downcast_ref::(), diff --git a/codex-rs/tui/src/config_update_tests.rs b/codex-rs/tui/src/config_update_tests.rs index c67888757e4d..7f0f7c9efdd5 100644 --- a/codex-rs/tui/src/config_update_tests.rs +++ b/codex-rs/tui/src/config_update_tests.rs @@ -67,6 +67,19 @@ async fn remote_project_trust_guards_thread_start_and_preserves_repository_decis let read_trust = async |cwd: &Path, host| { read_remote_project_trust(app_server.request_handle(), cwd, host).await }; + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + let non_utf8 = project_root.join(std::ffi::OsString::from_vec(vec![0xff])); + let error = read_trust(&non_utf8, ProjectTrustHost::Local) + .await + .expect_err("trust checks must not inspect a lossy replacement path"); + assert!( + error + .to_string() + .contains("directory path is not valid UTF-8") + ); + } let relative_cwd = pathdiff::diff_paths(&project_cwd, std::env::current_dir()?) .ok_or_else(|| color_eyre::eyre::eyre!("failed to calculate relative project path"))?; @@ -262,17 +275,28 @@ async fn remote_project_trust_guards_thread_start_and_preserves_repository_decis } assert_eq!( read_trust(&canonical_project_cwd, ProjectTrustHost::Local).await?, - Some(RemoteProjectTrust { - trust_level: marked_root.then_some(TrustLevel::Untrusted), + marked_root.then(|| RemoteProjectTrust { + trust_level: Some(TrustLevel::Untrusted), cwd: canonical_project_cwd.clone(), - trust_target: if marked_root { - PathBuf::from(project_trust_key(&project_root)) - } else { - canonical_project_cwd.clone() - }, + trust_target: PathBuf::from(project_trust_key(&project_root)), }) ); } + // A gitfile checkout is still a project when configured markers exclude Git. + write_config_batch( + app_server.request_handle(), + vec![replace_config_value( + "project_root_markers", + serde_json::json!([]), + )], + ) + .await?; + std::fs::write(project_cwd.join(".git"), "gitdir: ../separate-git-dir\n")?; + assert!( + read_trust(&canonical_project_cwd, ProjectTrustHost::Local) + .await? + .is_some() + ); app_server.shutdown().await?; Ok(()) } @@ -292,3 +316,24 @@ fn format_config_error_preserves_server_validation_message() { features.fast_mode=true violates managed requirements; allowed set [fast_mode=false]" ); } + +#[test] +fn effective_defaults_accept_foreign_layer_paths() -> Result<()> { + let layers = serde_json::json!([ + {"name": {"type": "system", "file": "/etc/codex/config.toml"}, "version": "1", "config": {}}, + {"name": {"type": "project", "dotCodexFolder": "C:\\work\\.codex"}, "disabledReason": "untrusted", "version": "1", "config": {}} + ]); + let response: EffectiveConfig = serde_json::from_value(serde_json::json!({ + "config": {"model": "server-model"}, + "origins": {"model": {"name": {"type": "user", "file": "C:\\Users\\user\\.codex\\config.toml", "profile": null}, "version": "1"}}, + "layers": layers, + }))?; + assert_eq!( + ( + response.config.model.as_deref(), + serde_json::to_value(response.layers)? + ), + (Some("server-model"), layers) + ); + Ok(()) +} diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index f9e2c41b50f5..1a4657371456 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -89,7 +89,9 @@ impl KeyBinding { ] { if self.modifiers.contains(modifier) { label.push_str(name); - label.push('+'); + if !matches!(name, "⌥" | "⌘" | "^") { + label.push('+'); + } } } let key = match self.key { diff --git a/codex-rs/tui/src/key_hint_label_tests.rs b/codex-rs/tui/src/key_hint_label_tests.rs index be15328df9e2..d717f8b3871f 100644 --- a/codex-rs/tui/src/key_hint_label_tests.rs +++ b/codex-rs/tui/src/key_hint_label_tests.rs @@ -11,9 +11,9 @@ use ratatui::widgets::Paragraph; fn modifier_combinations_stay_compact_inside_chords() { let cases = [ (KeyModifiers::CONTROL, "ctrl+t"), - (KeyModifiers::ALT, "⌥+t"), + (KeyModifiers::ALT, "⌥t"), (KeyModifiers::CONTROL | KeyModifiers::SHIFT, "ctrl+shift+t"), - (KeyModifiers::CONTROL | KeyModifiers::ALT, "ctrl+⌥+t"), + (KeyModifiers::CONTROL | KeyModifiers::ALT, "ctrl+⌥t"), ]; for (modifiers, expected) in cases { let prefix = KeyBinding::new(KeyCode::Char('t'), modifiers); diff --git a/codex-rs/tui/src/keymap/chords_tests.rs b/codex-rs/tui/src/keymap/chords_tests.rs index 148259f52e6b..451faa57789e 100644 --- a/codex-rs/tui/src/keymap/chords_tests.rs +++ b/codex-rs/tui/src/keymap/chords_tests.rs @@ -589,8 +589,8 @@ fn pending_hint_skips_whole_oversized_configured_completions() { assert_eq!( scenes, [ - "80: ctrl+x then · shift+⌥+f11 activity · ctrl+shift+⌥+f12 copy · esc cancel", - "32: shift+⌥+f11 inspect · esc cancel", + "80: ctrl+x then · shift+⌥f11 activity · ctrl+shift+⌥f12 copy · esc cancel", + "32: shift+⌥f11 inspect · esc cancel", "12: esc cancel", ] ); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 8da049ef4fba..d1c672d9acf4 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -183,9 +183,11 @@ mod npm_registry; pub(crate) mod onboarding; mod oss_selection; mod pager_overlay; +mod projectless; pub(crate) mod public_widgets; mod render; mod resize_reflow_cap; +mod resume_permissions; mod resume_picker; mod screen_reader; mod service_tier_resolution; @@ -806,15 +808,14 @@ async fn lookup_session_target_with_app_server( }; } - let model_provider = - (!app_server.uses_remote_workspace()).then_some(config.model_provider_id.as_str()); + let model_provider = app_server.history_model_provider(config).await?; Ok(named_session_lookup::lookup( app_server, config.codex_home.as_path(), id_or_name, &[named_session_lookup::SessionCollection::Active], &[resume_source_kinds(/*include_non_interactive*/ false)], - model_provider, + model_provider.as_deref(), ) .await? .and_then(session_target_from_app_server_thread)) @@ -828,6 +829,7 @@ async fn lookup_latest_session_target_with_app_server( include_non_interactive: bool, ) -> color_eyre::Result> { let uses_remote_workspace = app_server.uses_remote_workspace(); + let model_provider = app_server.history_model_provider(config).await?; for lookup_mode in [ LatestSessionLookupMode::StateDbOnly, LatestSessionLookupMode::ScanAndRepair, @@ -835,7 +837,7 @@ async fn lookup_latest_session_target_with_app_server( let response = app_server .thread_list(latest_session_lookup_params( uses_remote_filesystem, - uses_remote_workspace, + model_provider.clone(), config, cwd_filter, include_non_interactive, @@ -863,7 +865,7 @@ enum LatestSessionLookupMode { fn latest_session_lookup_params( uses_remote_filesystem: bool, - uses_remote_workspace: bool, + model_provider: Option, config: &Config, cwd_filter: Option<&Path>, include_non_interactive: bool, @@ -875,11 +877,7 @@ fn latest_session_lookup_params( limit: Some(1), sort_key: Some(AppServerThreadSortKey::UpdatedAt), sort_direction: None, - model_providers: if uses_remote_workspace { - None - } else { - Some(vec![config.model_provider_id.clone()]) - }, + model_providers: model_provider.map(|provider| vec![provider]), source_kinds: Some(resume_source_kinds(include_non_interactive)), archived: Some(false), section_id: None, @@ -1212,7 +1210,7 @@ async fn run_ratatui_app( ) .await; launch_telemetry.record(&app_server_target, matches!(&startup_app_server, Ok(Ok(_)))); - let app_server_session = match startup_app_server { + let mut app_server_session = match startup_app_server { Ok(Ok(app_server)) => { AppServerSession::new(app_server, app_server_target.thread_params_mode()) .with_local_codex_home(&initial_config.codex_home) @@ -1255,6 +1253,7 @@ async fn run_ratatui_app( } } } + app_server_session.model_provider_override = overrides.model_provider.clone(); let mut app_server = Some(app_server_session); // Folder consent runs after the picker resolves the actual destination. let should_show_trust_screen_flag = false; @@ -1828,7 +1827,10 @@ async fn run_ratatui_app( &config, &app_server_target, trust_cwd, - resumed_thread.as_ref(), + onboarding::DirectoryTrustOptions { + resumed_thread: resumed_thread.as_ref(), + ..Default::default() + }, Some(&mut startup_draft), ) .await?; @@ -1952,7 +1954,7 @@ async fn run_ratatui_app( } set_default_client_residency_requirement(config.enforce_residency.value()); - let should_show_trust_screen = should_show_trust_screen(&config); + let is_first_run = config.active_project.trust_level.is_none(); #[cfg(target_os = "windows")] let should_prompt_windows_sandbox_nux_at_startup = trust_decision_was_made; #[cfg(not(target_os = "windows"))] @@ -2041,7 +2043,7 @@ async fn run_ratatui_app( images, session_selection, feedback, - should_show_trust_screen, // Proxy to: is it a first run in this directory? + is_first_run, should_prompt_windows_sandbox_nux_at_startup, app_server_target, state_db, @@ -2277,11 +2279,6 @@ async fn load_bootstrap_config_or_exit( } } -/// Determine if the user has decided whether to trust the current directory. -fn should_show_trust_screen(config: &Config) -> bool { - config.active_project.trust_level.is_none() -} - fn should_show_onboarding( login_status: LoginStatus, requires_openai_auth: bool, @@ -2343,10 +2340,8 @@ pub(crate) mod tests { use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; - use codex_config::config_toml::ProjectConfig; use codex_utils_absolute_path::test_support::PathExt; use pretty_assertions::assert_eq; - use serial_test::serial; use tempfile::TempDir; #[test] @@ -3209,17 +3204,14 @@ requires_openai_auth = {requires_openai_auth} let params = latest_session_lookup_params( /*uses_remote_filesystem*/ false, - /*uses_remote_workspace*/ false, + /*model_provider*/ None, &config, Some(cwd.as_path()), /*include_non_interactive*/ false, LatestSessionLookupMode::StateDbOnly, ); - assert_eq!( - params.model_providers, - Some(vec![config.model_provider_id.clone()]) - ); + assert_eq!(params.model_providers, None); assert_eq!( params.cwd, Some(ThreadListCwdFilter::One(cwd.to_string_lossy().to_string())) @@ -3228,7 +3220,7 @@ requires_openai_auth = {requires_openai_auth} let scan_params = latest_session_lookup_params( /*uses_remote_filesystem*/ false, - /*uses_remote_workspace*/ false, + /*model_provider*/ None, &config, Some(cwd.as_path()), /*include_non_interactive*/ false, @@ -3239,28 +3231,23 @@ requires_openai_auth = {requires_openai_auth} } #[tokio::test] - async fn latest_session_lookup_params_keep_local_filters_for_local_daemon_sessions() - -> color_eyre::Result<()> { + async fn latest_session_lookup_params_honor_explicit_provider() -> color_eyre::Result<()> { let temp_dir = TempDir::new()?; let config = build_config(&temp_dir).await?; let cwd = temp_dir.path().join("project"); - let target = AppServerTarget::LocalDaemon { - allow_embedded_fallback: true, - endpoint: RemoteAppServerEndpoint::UnixSocket { - socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, - }, - }; - let params = latest_session_lookup_params( /*uses_remote_filesystem*/ false, - target.uses_remote_workspace(), + Some("selected-provider".to_string()), &config, Some(cwd.as_path()), /*include_non_interactive*/ false, LatestSessionLookupMode::StateDbOnly, ); - assert_eq!(params.model_providers, Some(vec![config.model_provider_id])); + assert_eq!( + params.model_providers, + Some(vec!["selected-provider".to_string()]) + ); assert_eq!( params.cwd, Some(ThreadListCwdFilter::One(cwd.to_string_lossy().to_string())) @@ -3276,7 +3263,7 @@ requires_openai_auth = {requires_openai_auth} let params = latest_session_lookup_params( /*uses_remote_filesystem*/ true, - /*uses_remote_workspace*/ true, + /*model_provider*/ None, &config, /*cwd_filter*/ None, /*include_non_interactive*/ false, @@ -3296,7 +3283,7 @@ requires_openai_auth = {requires_openai_auth} let params = latest_session_lookup_params( /*uses_remote_filesystem*/ true, - /*uses_remote_workspace*/ true, + /*model_provider*/ None, &config, /*cwd_filter*/ None, /*include_non_interactive*/ true, @@ -3324,7 +3311,7 @@ requires_openai_auth = {requires_openai_auth} let params = latest_session_lookup_params( /*uses_remote_filesystem*/ true, - /*uses_remote_workspace*/ true, + /*model_provider*/ None, &config, Some(cwd), /*include_non_interactive*/ false, @@ -3654,6 +3641,12 @@ requires_openai_auth = {requires_openai_auth} )?; assert_eq!(config_cwd, None); + assert!(!projectless::has_only_local_environments( + &environment_manager + )); + assert!(projectless::has_only_local_environments( + &EnvironmentManager::default_for_tests() + )); assert!(uses_remote_workspace_or_environment( &target, &environment_manager @@ -3671,22 +3664,6 @@ requires_openai_auth = {requires_openai_auth} Ok(()) } - #[tokio::test] - #[serial] - async fn windows_shows_trust_prompt_without_sandbox() -> std::io::Result<()> { - let temp_dir = TempDir::new()?; - let mut config = build_config(&temp_dir).await?; - config.active_project = ProjectConfig { trust_level: None }; - config.set_windows_sandbox_enabled(/*value*/ false); - - let should_show = should_show_trust_screen(&config); - assert!( - should_show, - "Trust prompt should be shown when project trust is undecided" - ); - Ok(()) - } - #[tokio::test] async fn embedded_app_server_supports_thread_start_rpc() -> color_eyre::Result<()> { let temp_dir = TempDir::new()?; @@ -3922,45 +3899,6 @@ requires_openai_auth = {requires_openai_auth} Ok(()) } - #[tokio::test] - #[serial] - async fn windows_shows_trust_prompt_with_sandbox() -> std::io::Result<()> { - let temp_dir = TempDir::new()?; - let mut config = build_config(&temp_dir).await?; - config.active_project = ProjectConfig { trust_level: None }; - config.set_windows_sandbox_enabled(/*value*/ true); - - let should_show = should_show_trust_screen(&config); - if cfg!(target_os = "windows") { - assert!( - should_show, - "Windows trust prompt should be shown on native Windows with sandbox enabled" - ); - } else { - assert!( - should_show, - "Non-Windows should still show trust prompt when project is untrusted" - ); - } - Ok(()) - } - #[tokio::test] - async fn untrusted_project_skips_trust_prompt() -> std::io::Result<()> { - use codex_protocol::config_types::TrustLevel; - let temp_dir = TempDir::new()?; - let mut config = build_config(&temp_dir).await?; - config.active_project = ProjectConfig { - trust_level: Some(TrustLevel::Untrusted), - }; - - let should_show = should_show_trust_screen(&config); - assert!( - !should_show, - "Trust prompt should not be shown for projects explicitly marked as untrusted" - ); - Ok(()) - } - #[tokio::test] async fn config_rebuild_changes_trust_defaults_with_cwd() -> std::io::Result<()> { let temp_dir = TempDir::new()?; diff --git a/codex-rs/tui/src/markdown_copy.rs b/codex-rs/tui/src/markdown_copy.rs index ae135e1b75e7..d2513211a828 100644 --- a/codex-rs/tui/src/markdown_copy.rs +++ b/codex-rs/tui/src/markdown_copy.rs @@ -271,6 +271,55 @@ pub(crate) fn selection(lines: &[SelectedLine], plain: &str) -> (String, CopyFor { return (plain.to_owned(), CopyFormat::PlainText); } + // Quote-only selections omit quote markers. Tables and task lists still need their + // semantic metadata to reconstruct cell fragments and preserve checkbox state. + let mut content = lines + .iter() + .filter(|line| { + !line.range.is_empty() && !line.source.copy.as_ref().is_some_and(|copy| copy.omit) + }) + .peekable(); + let quote_only = content.peek().is_some() + && content.all(|line| { + line.source + .copy + .as_ref() + .is_some_and(|copy| copy.prefix.contains("> ")) + }); + let unquoted; + let lines = if quote_only { + if !lines.iter().any(|line| { + (!line.range.is_empty() || line.source.text.is_empty()) + && line.source.copy.as_ref().is_some_and(|copy| { + !copy.omit + && (copy.table.is_some() + || copy.item_prefix.contains("[x] ") + || copy.item_prefix.contains("[ ] ")) + }) + }) { + return (plain.to_owned(), CopyFormat::PlainText); + } + unquoted = lines + .iter() + .map(|line| { + let mut source = line.source.clone(); + if let Some(copy) = source.copy.as_mut() { + let copy = Arc::make_mut(copy); + copy.prefix = copy.prefix.replace("> ", ""); + copy.continuation = copy.continuation.replace("> ", ""); + copy.item_prefix = copy.item_prefix.replace("> ", ""); + } + SelectedLine { + source, + range: line.range.clone(), + separator: line.separator.clone(), + } + }) + .collect::>(); + &unquoted + } else { + lines + }; let rich = lines.iter().any(|line| { !line.range.is_empty() && line diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index 6c129f3b764a..c5033f8045b8 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -997,8 +997,9 @@ impl<'a, 'policy> Writer<'a, 'policy> { let index = self.text.len(); self.push_blank_line(); if let Some(line) = self.text.get_mut(index) { - let mut source = - crate::terminal_hyperlinks::LogicalLineSource::from_line(&line.line); + let mut source = line.source.clone().unwrap_or_else(|| { + crate::terminal_hyperlinks::LogicalLineSource::from_line(&line.line) + }); let mut copy = crate::markdown_copy::CopyLine::default(); copy.omit = true; source.copy = Some(std::sync::Arc::new(copy)); diff --git a/codex-rs/tui/src/onboarding/directory_trust.rs b/codex-rs/tui/src/onboarding/directory_trust.rs index 2f90e32e1d7b..fde87b6c0a52 100644 --- a/codex-rs/tui/src/onboarding/directory_trust.rs +++ b/codex-rs/tui/src/onboarding/directory_trust.rs @@ -9,7 +9,6 @@ use super::run_onboarding_screen; use crate::AppServerTarget; use crate::app_server_session::AppServerSession; use crate::config_update::ProjectTrustHost; -use crate::config_update::RemoteProjectTrust; use crate::config_update::read_remote_project_trust; use crate::legacy_core::config::Config; use crate::onboarding::trust_directory::TrustCancelAction; @@ -22,23 +21,31 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; -use codex_exec_server::LOCAL_FS; -use codex_git_utils::resolve_root_git_project_for_trust; use codex_protocol::config_types::TrustLevel; use color_eyre::eyre::Result; use std::collections::VecDeque; use std::path::Path; use uuid::Uuid; +#[derive(Clone, Copy, Default)] +pub(crate) struct DirectoryTrustOptions<'a> { + pub resumed_thread: Option<&'a Thread>, + pub cancel: Option, +} + pub(crate) async fn check_directory_trust( tui: &mut Tui, app_server: &AppServerSession, config: &Config, target: &AppServerTarget, cwd: &Path, - resumed_thread: Option<&Thread>, + options: DirectoryTrustOptions<'_>, mut startup_draft: Option<&mut StartupDraftPump>, ) -> Result { + let DirectoryTrustOptions { + resumed_thread, + cancel, + } = options; let connected = !matches!(target, AppServerTarget::Embedded); let mut consent = OnboardingResult::default(); // Another client can load the saved task while consent is pending. Check both folders. @@ -58,30 +65,12 @@ pub(crate) async fn check_directory_trust( } checked_cwds.push(cwd.clone()); let cwd = cwd.as_path(); - let lookup = async { - if connected { - let host = if matches!(target, AppServerTarget::LocalDaemon { .. }) { - ProjectTrustHost::Local - } else { - ProjectTrustHost::Remote - }; - read_remote_project_trust(app_server.request_handle(), cwd, host).await - } else if config.active_project.trust_level == Some(TrustLevel::Trusted) { - Ok(None) - } else { - Ok(Some(RemoteProjectTrust { - cwd: cwd.to_path_buf(), - trust_target: resolve_root_git_project_for_trust( - LOCAL_FS.as_ref(), - &config.cwd, - ) - .await - .map(Into::into) - .unwrap_or_else(|| cwd.to_path_buf()), - trust_level: config.active_project.trust_level, - })) - } + let host = if !target.uses_remote_workspace() { + ProjectTrustHost::Local + } else { + ProjectTrustHost::Remote }; + let lookup = read_remote_project_trust(app_server.request_handle(), cwd, host); let project = if let Some(draft) = startup_draft.as_deref_mut() { draft.run_until(tui, lookup).await?? } else { @@ -95,6 +84,14 @@ pub(crate) async fn check_directory_trust( if target.uses_remote_workspace() && project.trust_level == Some(TrustLevel::Untrusted) { continue; } + if cancel == Some(TrustCancelAction::CurrentTask) + && project.trust_level == Some(TrustLevel::Untrusted) + && !config.active_project.is_untrusted() + { + color_eyre::eyre::bail!( + "Folder trust changed. Reopen the destination with its restricted settings." + ); + } let remote_trust_key = connected.then(|| project.trust_target.to_string_lossy().into_owned()); if let Some(draft) = startup_draft.as_deref_mut() { @@ -105,11 +102,11 @@ pub(crate) async fn check_directory_trust( steps: vec![Step::TrustDirectory(TrustDirectoryWidget { restricted: project.trust_level == Some(TrustLevel::Untrusted), existing_task: connected && resumed_thread.is_some(), - cancel: if connected { + cancel: cancel.unwrap_or(if connected { TrustCancelAction::AgentsOverview } else { TrustCancelAction::Quit - }, + }), cwd: project.cwd, trust_target: project.trust_target, show_windows_create_sandbox_hint: false, diff --git a/codex-rs/tui/src/onboarding/mod.rs b/codex-rs/tui/src/onboarding/mod.rs index c5438a1e515d..6a9ea9612444 100644 --- a/codex-rs/tui/src/onboarding/mod.rs +++ b/codex-rs/tui/src/onboarding/mod.rs @@ -5,4 +5,6 @@ pub(crate) mod onboarding_screen; mod trust_directory; pub(crate) use auth::mark_underlined_hyperlink; pub(crate) use auth::mark_url_hyperlink; +pub(crate) use onboarding_screen::DirectoryTrustOptions; +pub(crate) use trust_directory::TrustCancelAction; mod welcome; diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 415c7d63762f..0ec1006eca41 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -61,6 +61,7 @@ use uuid::Uuid; #[path = "directory_trust.rs"] mod directory_trust; +pub(crate) use directory_trust::DirectoryTrustOptions; pub(crate) use directory_trust::check_directory_trust; #[allow(clippy::large_enum_variant)] diff --git a/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__directory_change_consent_can_keep_current_directory.snap b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__directory_change_consent_can_keep_current_directory.snap new file mode 100644 index 000000000000..802d9e87d54d --- /dev/null +++ b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__directory_change_consent_can_keep_current_directory.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/onboarding/trust_directory.rs +assertion_line: 365 +expression: terminal.backend() +--- + + Folder access + /workspace/project + + Trust this folder? Codex can read, edit, and run files here, + subject to your permission settings. Folder settings can run code + automatically, even without a model request. Continue only if you + trust these files. Your trust decision will be saved. + +› 1. Trust and continue + 2. Keep current directory + + enter continue · esc cancel diff --git a/codex-rs/tui/src/onboarding/trust_directory.rs b/codex-rs/tui/src/onboarding/trust_directory.rs index baab5861eb55..64ea9ce98e86 100644 --- a/codex-rs/tui/src/onboarding/trust_directory.rs +++ b/codex-rs/tui/src/onboarding/trust_directory.rs @@ -44,6 +44,7 @@ pub(crate) struct TrustDirectoryWidget { pub(crate) enum TrustCancelAction { Quit, AgentsOverview, + CurrentTask, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -91,6 +92,7 @@ impl WidgetRef for &TrustDirectoryWidget { match self.cancel { TrustCancelAction::Quit => "Quit", TrustCancelAction::AgentsOverview => "Back to Agent Command Center", + TrustCancelAction::CurrentTask => "Keep current directory", }, TrustDirectorySelection::Quit, ), @@ -130,6 +132,7 @@ impl WidgetRef for &TrustDirectoryWidget { match self.cancel { TrustCancelAction::Quit => " quit", TrustCancelAction::AgentsOverview => " back", + TrustCancelAction::CurrentTask => " cancel", } .dim(), ])) @@ -353,6 +356,20 @@ mod tests { insta::assert_snapshot!(terminal.backend()); } + #[test] + fn directory_change_consent_can_keep_current_directory() { + let mut widget = widget(/*error*/ None); + widget.cancel = TrustCancelAction::CurrentTask; + let mut terminal = + Terminal::new(VT100Backend::new(/*width*/ 70, /*height*/ 18)).expect("terminal"); + terminal + .draw(|f| (&widget).render_ref(f.area(), f.buffer_mut())) + .expect("draw"); + insta::assert_snapshot!(terminal.backend()); + widget.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(widget.should_quit()); + } + #[test] fn renders_snapshot_for_remote_git_subdirectory() { let widget = TrustDirectoryWidget { diff --git a/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript_tests__transcript_overlay_paginated_history_states.snap b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript_tests__transcript_overlay_paginated_history_states.snap index 94e53e1df6b3..2fcab53e1ad0 100644 --- a/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript_tests__transcript_overlay_paginated_history_states.snap +++ b/codex-rs/tui/src/pager_overlay/snapshots/codex_tui__pager_overlay__transcript_tests__transcript_overlay_paginated_history_states.snap @@ -1,6 +1,5 @@ --- source: tui/src/pager_overlay/transcript_tests.rs -assertion_line: 417 expression: snapshots --- --- loading --- @@ -32,7 +31,7 @@ recent transcript -Retry history: ⌥+ bool { + let defaults = &response.config; + let effective = config.config_layer_stack.effective_config(); + if response.layers.as_ref().is_none_or(|layers| layers.iter().any(|layer| { + layer["name"]["type"] == "project" + })) + || app_server.uses_remote_workspace() + || defaults.sandbox_mode.is_some() + || defaults.sandbox_workspace_write.is_some() + || ["default_permissions", "permissions", "network"].iter() + .any(|key| defaults.additional.get(*key).is_some_and(|value| !value.is_null())) + // Reuse local discovery only when server markers and trust decisions agree. + || ["project_root_markers", "projects"].iter().any(|key| + serde_json::to_value(effective.get(*key)).ok().as_ref() + != Some(defaults.additional.get(*key).unwrap_or(&serde_json::Value::Null))) + || !config.config_layer_stack.is_projectless() + || config.active_project.trust_level.is_some() + || config.workspace_roots.len() != 1 + || config.workspace_roots.first() != Some(&config.cwd) + || !has_only_local_environments(environments) + || overrides.sandbox_mode.is_some() + || overrides.permission_profile.is_some() + || overrides.default_permissions.is_some() + || [ + "sandbox_mode", + "default_permissions", + "permissions", + "sandbox_workspace_write", + "network", + ] + .iter() + .any(|key| effective.get(*key).is_some()) + || config + .config_layer_stack + .requirements_toml() + .default_permissions + .is_some() + || !config.is_permission_profile_allowed( + BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + &PermissionProfile::workspace_write(), + ) + { + return false; + } + let granular = AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: false, + rules: false, + skill_approval: false, + request_permissions: true, + mcp_elicitations: true, + }); + if overrides.approval_policy.is_none() && effective.get("approval_policy").is_none() { + let granular = defaults + .approval_policy + .as_ref() + .map_or(granular, |policy| policy.to_core()); + let _ = config.permissions.approval_policy.set(granular); + } + #[cfg(target_os = "windows")] + if config.effective_local_windows_sandbox_type() == codex_sandboxing::SandboxType::None { + return true; + } + if config + .permissions + .set_permission_profile(PermissionProfile::workspace_write()) + .is_err() + { + return false; + } + let snapshot = PermissionProfileSnapshot::active( + config.permissions.permission_profile().clone(), + ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()), + ); + let _ = config + .permissions + .set_permission_profile_from_session_snapshot(snapshot); + false +} + +/// Local filesystem discovery is authoritative only for positively local execution. +pub(crate) fn has_only_local_environments(environments: &EnvironmentManager) -> bool { + let ids = environments.default_environment_ids(); + !ids.is_empty() + && ids.iter().all(|id| { + environments + .get_environment(id) + .is_some_and(|environment| !environment.is_remote()) + }) +} diff --git a/codex-rs/tui/src/resume_permissions.rs b/codex-rs/tui/src/resume_permissions.rs new file mode 100644 index 000000000000..cf6fc5b4c19f --- /dev/null +++ b/codex-rs/tui/src/resume_permissions.rs @@ -0,0 +1,57 @@ +//! Tracks direct launch permission choices separately from configuration defaults. +//! Omitted choices let app-server restore the destination task's saved settings. + +use crate::legacy_core::config::Config; +use crate::legacy_core::config::ConfigOverrides; +use codex_config::ConfigLayerSource; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct ResumePermissions { + pub approval_policy: bool, + pub approvals_reviewer: bool, + pub profile: bool, + pub workspace_roots: bool, +} + +impl ResumePermissions { + pub(crate) fn from_overrides(config: &Config, overrides: &ConfigOverrides) -> Self { + let has = |path: &str| { + config.config_layer_stack.layers_high_to_low().any(|layer| { + matches!(layer.name, ConfigLayerSource::SessionFlags) + && path + .split('.') + .try_fold(&layer.config, |value, key| value.get(key)) + .is_some() + }) + }; + Self { + approval_policy: overrides.approval_policy.is_some() || has("approval_policy"), + approvals_reviewer: overrides.approvals_reviewer.is_some() || has("approvals_reviewer"), + profile: overrides.sandbox_mode.is_some() + || overrides.permission_profile.is_some() + || overrides.default_permissions.is_some() + || !overrides.additional_writable_roots.is_empty() + || has("default_permissions") + || has("sandbox_mode") + || has("sandbox_workspace_write") + || has("permissions") + || has("network"), + workspace_roots: overrides.cwd.is_some() + || overrides.workspace_roots.is_some() + || !overrides.additional_writable_roots.is_empty() + || has("sandbox_workspace_write.writable_roots"), + } + } + + #[cfg(test)] + pub(crate) const CURRENT_CONFIG: Self = Self { + approval_policy: true, + approvals_reviewer: true, + profile: true, + workspace_roots: true, + }; +} + +#[cfg(test)] +#[path = "resume_permissions_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/resume_permissions_tests.rs b/codex-rs/tui/src/resume_permissions_tests.rs new file mode 100644 index 000000000000..b9f68fada78d --- /dev/null +++ b/codex-rs/tui/src/resume_permissions_tests.rs @@ -0,0 +1,63 @@ +use super::ResumePermissions; +use crate::legacy_core::config::ConfigBuilder; +use crate::legacy_core::config::ConfigOverrides; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn only_direct_launch_choices_override_saved_permissions() -> color_eyre::Result<()> { + let home = tempfile::tempdir()?; + let profile = home.path().join("work.config.toml"); + std::fs::write( + &profile, + "default_permissions = \":read-only\"\napproval_policy = \"never\"\napprovals_reviewer = \"auto_review\"\n", + )?; + let builder = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .loader_overrides(codex_config::LoaderOverrides { + user_config_path: Some(AbsolutePathBuf::try_from(profile)?), + user_config_profile: Some("work".parse()?), + ..codex_config::LoaderOverrides::without_managed_config_for_tests() + }); + let config = builder.clone().build().await?; + assert_eq!( + ResumePermissions::from_overrides(&config, &ConfigOverrides::default()), + ResumePermissions::default(), + ); + let direct_rules = builder + .clone() + .cli_overrides(vec![( + "permissions.safe.extends".into(), + ":read-only".into(), + )]) + .build() + .await?; + assert_eq!( + ResumePermissions::from_overrides(&direct_rules, &ConfigOverrides::default()), + ResumePermissions { + profile: true, + ..Default::default() + }, + ); + let config = builder + .cli_overrides(vec![("approval_policy".into(), "on-request".into())]) + .build() + .await?; + assert_eq!( + ResumePermissions::from_overrides( + &config, + &ConfigOverrides { + default_permissions: Some(":read-only".into()), + cwd: Some(home.path().to_path_buf()), + ..Default::default() + }, + ), + ResumePermissions { + approval_policy: true, + profile: true, + workspace_roots: true, + ..Default::default() + }, + ); + Ok(()) +} diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index cc04b6e551c2..6742919a217f 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -442,7 +442,7 @@ async fn run_resume_picker_with_launch_context( app_server.remote_cwd_override(), ); let local_filter_cwd = local_picker_cwd_filter(&cwd_filter, uses_remote_filesystem); - let provider_filter = picker_provider_filter(config, uses_remote_workspace); + let provider_filter = picker_provider_filter(config, &app_server).await?; let runtime_keymap = picker_runtime_keymap(local_settings)?; let options = SessionPickerRunOptions { use_theme_colors: local_settings.tui.status_line_use_colors, @@ -502,7 +502,7 @@ pub async fn run_fork_picker_with_app_server( app_server.remote_cwd_override(), ); let local_filter_cwd = local_picker_cwd_filter(&cwd_filter, uses_remote_filesystem); - let provider_filter = picker_provider_filter(config, uses_remote_workspace); + let provider_filter = picker_provider_filter(config, &app_server).await?; let runtime_keymap = picker_runtime_keymap(local_settings)?; let options = SessionPickerRunOptions { use_theme_colors: local_settings.tui.status_line_use_colors, @@ -658,12 +658,14 @@ fn local_picker_cwd_filter( } } -fn picker_provider_filter(config: &Config, uses_remote_workspace: bool) -> ProviderFilter { - if uses_remote_workspace { - ProviderFilter::Any - } else { - ProviderFilter::MatchDefault(config.model_provider_id.to_string()) - } +async fn picker_provider_filter( + config: &Config, + app_server: &AppServerSession, +) -> Result { + Ok(app_server + .history_model_provider(config) + .await? + .map_or(ProviderFilter::Any, ProviderFilter::MatchDefault)) } fn picker_runtime_keymap(config: &crate::local_settings::LocalSettings) -> Result { @@ -3876,7 +3878,7 @@ mod tests { )); let mut local = crate::latest_session_lookup_params( /*uses_remote_filesystem*/ false, - /*uses_remote_workspace*/ false, + /*model_provider*/ None, &config, Some(&primary), /*include_non_interactive*/ false, @@ -3887,7 +3889,7 @@ mod tests { assert_eq!( crate::latest_session_lookup_params( /*uses_remote_filesystem*/ true, - /*uses_remote_workspace*/ false, + /*model_provider*/ None, &config, Some(&primary), /*include_non_interactive*/ false, @@ -4306,6 +4308,37 @@ mod tests { assert!(first_index < second_index); } + #[test] + fn picker_renders_server_provider_history() { + let cwd = PathBuf::from("/project"); + let mut row = make_row( + "/session.jsonl", + "2025-01-02T11:00:00Z", + "Other provider session", + ); + row.cwd = Some(cwd.clone()); + let mut state = PickerState::new( + FrameRequester::test_dummy(), + page_only_loader(|_| {}), + ProviderFilter::MatchDefault("server-provider".to_string()), + /*show_all*/ false, + Some(cwd), + SessionPickerAction::Resume, + ); + state.ingest_page(page( + vec![row], + /*next_cursor*/ None, + /*num_scanned_files*/ 1, + /*reached_scan_cap*/ false, + )); + state.relative_time_reference = Some(parse_timestamp_str("2025-01-02T12:00:00Z").unwrap()); + state.update_viewport(/*rows*/ 12, /*width*/ 80); + assert_snapshot!( + "resume_picker_shared_provider_history", + render_picker_list(&state, /*width*/ 80, /*height*/ 12) + ); + } + #[test] fn remote_thread_list_params_omit_provider_filter() { let params = thread_list_params( diff --git a/codex-rs/tui/src/session_start.rs b/codex-rs/tui/src/session_start.rs index ba41e706e5d1..08a6fc82205b 100644 --- a/codex-rs/tui/src/session_start.rs +++ b/codex-rs/tui/src/session_start.rs @@ -16,14 +16,17 @@ use color_eyre::eyre::WrapErr; #[derive(Clone, Copy)] pub(crate) enum SessionStartAction { - Resume(ResumeModelSettings), + Resume( + ResumeModelSettings, + crate::resume_permissions::ResumePermissions, + ), Fork(ForkPermissionMode), } impl SessionStartAction { pub(crate) fn verb(self) -> &'static str { match self { - Self::Resume(_) => "resume", + Self::Resume(..) => "resume", Self::Fork(_) => "fork", } } @@ -36,9 +39,15 @@ impl SessionStartAction { target: &SessionTarget, ) -> Result { match self { - Self::Resume(settings) => { + Self::Resume(settings, permissions) => { app_server - .resume_thread(local_settings, config.clone(), target.thread_id, settings) + .resume_thread_with_permission_overrides( + local_settings, + config.clone(), + target.thread_id, + settings, + permissions, + ) .await } Self::Fork(permission_mode) => { diff --git a/codex-rs/tui/src/session_start_tests.rs b/codex-rs/tui/src/session_start_tests.rs index 3be0a4d0acdb..42666f541ea3 100644 --- a/codex-rs/tui/src/session_start_tests.rs +++ b/codex-rs/tui/src/session_start_tests.rs @@ -37,7 +37,10 @@ async fn start_session( #[tokio::test] async fn archived_session_requires_confirmation_before_resume_or_fork() -> Result<()> { for action in [ - SessionStartAction::Resume(ResumeModelSettings::RestoreFromThread), + SessionStartAction::Resume( + ResumeModelSettings::RestoreFromThread, + crate::resume_permissions::ResumePermissions::default(), + ), SessionStartAction::Fork(crate::app_server_session::ForkPermissionMode::InheritSaved), ] { let codex_home = TempDir::new()?; @@ -134,7 +137,7 @@ async fn archived_session_requires_confirmation_before_resume_or_fork() -> Resul (7, false, true) ); match action { - SessionStartAction::Resume(_) => { + SessionStartAction::Resume(..) => { assert_eq!(started.session.thread_id, target.thread_id) } SessionStartAction::Fork(_) => { @@ -153,7 +156,10 @@ async fn archived_session_requires_confirmation_before_resume_or_fork() -> Resul &mut app_server, &config, &target, - SessionStartAction::Resume(ResumeModelSettings::RestoreFromThread), + SessionStartAction::Resume( + ResumeModelSettings::RestoreFromThread, + crate::resume_permissions::ResumePermissions::default(), + ), async || panic!("active sessions must not prompt"), ) .await? diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_reviewer_conflict.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_reviewer_conflict.snap new file mode 100644 index 000000000000..90ce69fd5039 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_reviewer_conflict.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/app/agents_overview_tests.rs +expression: "crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_widget, 96)" +--- + Unable to complete action + Failed to carry forward approvals reviewer: invalid value for `approvals_reviewer`: `User` + is not in the allowed set [AutoReview] (set by ) + + +› 1. Return to command center + + enter select · esc back diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__path_backed_agent_picker.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__path_backed_agent_picker.snap index ab3252682cd7..38f1898fc5bd 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__path_backed_agent_picker.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__path_backed_agent_picker.snap @@ -3,7 +3,7 @@ source: tui/src/app/tests.rs expression: "render_bottom_popup(&app.chat_widget, 80)" --- Subagents - Select an agent to watch. ⌥+← previous, ⌥+→ next. + Select an agent to watch. ⌥← previous, ⌥→ next. › 1. • /root/worker 00000000-0000-0000-0000-000000000123 diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__untracked_thread_notifications_agent_picker.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__untracked_thread_notifications_agent_picker.snap index c2024241e855..8a986b13e048 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__untracked_thread_notifications_agent_picker.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__untracked_thread_notifications_agent_picker.snap @@ -3,7 +3,7 @@ source: tui/src/app/tests.rs expression: "render_bottom_popup(&app.chat_widget, 80)" --- Subagents - Select an agent to watch. ⌥+← previous, ⌥+→ next. + Select an agent to watch. ⌥← previous, ⌥→ next. › 1. • Main [default] (current) 00000000-0000-0000-0000-000000000123 diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_shared_provider_history.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_shared_provider_history.snap new file mode 100644 index 000000000000..fd35730b23cd --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_shared_provider_history.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/resume_picker.rs +expression: "render_picker_list(&state, 80, 12)" +--- +› Other provider session + 1h ago  no branch diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 60300d079e59..73b6daf68c32 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -322,13 +322,15 @@ impl StatusHistoryCell { .map(|effort| effort.to_string()) .unwrap_or_else(|| "none".to_string()); config_entries.push(("reasoning effort", effort_value)); - config_entries.push(( - "reasoning summaries", - config - .model_reasoning_summary - .map(|summary| summary.to_string()) - .unwrap_or_else(|| "auto".to_string()), - )); + if remote_connection.is_none() { + config_entries.push(( + "reasoning summaries", + config + .model_reasoning_summary + .map(|summary| summary.to_string()) + .unwrap_or_else(|| "auto".to_string()), + )); + } } let (model_name, model_details) = compose_model_display(model_name, &config_entries); let approval = config_entries diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_local_background_server.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_local_background_server.snap index 163ee982b584..6cc0ff032c7d 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_local_background_server.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_local_background_server.snap @@ -11,7 +11,7 @@ expression: sanitized Server: Local background server - Model: gpt-5.1-codex-max (reasoning medium, summaries auto) + Model: gpt-5.1-codex-max (reasoning medium) Directory: [[workspace]] Permissions: Custom (workspace with network access, Ask for approval) Agents.md: diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_remote_server.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_remote_server.snap new file mode 100644 index 000000000000..8478305e3e6a --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_remote_server.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + + >_ OpenAI Codex (v0.0.0) + + Visit https://chatgpt.com/codex/settings/usage for up-to-date + information on rate limits and credits + + Server: unix:///tmp/codex-home/app-server-control/app-server- + control.sock (v0.133.0) + + Model: gpt-5.1-codex-max (reasoning medium) + Directory: [[workspace]] + Permissions: Custom (workspace with network access, Ask for approval) + Agents.md: + + Token usage: 750 total (500 input + 250 output) + Context window: 100% left (750 used / 272K) + Limits: data not available yet diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_default_reasoning_when_config_empty.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_default_reasoning_when_config_empty.snap index 03e80108e295..bf5bb867e34f 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_default_reasoning_when_config_empty.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_default_reasoning_when_config_empty.snap @@ -9,9 +9,6 @@ expression: sanitized Visit https://chatgpt.com/codex/settings/usage for up-to-date information on rate limits and credits - Server: unix:///tmp/codex-home/app-server-control/app-server- - control.sock (v0.133.0) - Model: gpt-5.1-codex-max (reasoning medium, summaries auto) Directory: [[workspace]] Permissions: Custom (workspace with network access, Ask for approval) diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index f501402ef397..d2016cabc5bc 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -1710,17 +1710,18 @@ async fn status_snapshot_uses_default_reasoning_when_config_empty() { .expect("timestamp"); for (is_local_daemon, snapshot) in [ ( - false, + None, "status_snapshot_uses_default_reasoning_when_config_empty", ), - (true, "status_snapshot_local_background_server"), + (Some(false), "status_snapshot_remote_server"), + (Some(true), "status_snapshot_local_background_server"), ] { - let remote_connection = RemoteConnectionStatus { + let remote_connection = is_local_daemon.map(|is_local_daemon| RemoteConnectionStatus { address: "unix:///tmp/codex-home/app-server-control/app-server-control.sock" .to_string(), version: "v0.133.0".to_string(), is_local_daemon, - }; + }); let model_slug = get_model_offline_for_tests(config.model.as_deref()); let token_info = token_info_for(&model_slug, &config, &usage); @@ -1728,7 +1729,7 @@ async fn status_snapshot_uses_default_reasoning_when_config_empty() { &config, /*requires_openai_auth*/ true, /*model_provider_id*/ None, - Some(&remote_connection), + remote_connection.as_ref(), account_display.as_ref(), Some(&token_info), &usage, diff --git a/codex-rs/tui/src/tooltips/snapshots/codex_tui__tooltips__keybinding_tests__configured_shortcut_tips_render_at_narrow_width.snap b/codex-rs/tui/src/tooltips/snapshots/codex_tui__tooltips__keybinding_tests__configured_shortcut_tips_render_at_narrow_width.snap index 8eb1c85b91eb..f90948016dd9 100644 --- a/codex-rs/tui/src/tooltips/snapshots/codex_tui__tooltips__keybinding_tests__configured_shortcut_tips_render_at_narrow_width.snap +++ b/codex-rs/tui/src/tooltips/snapshots/codex_tui__tooltips__keybinding_tests__configured_shortcut_tips_render_at_narrow_width.snap @@ -1,6 +1,5 @@ --- source: tui/src/tooltips/keybinding_tests.rs -assertion_line: 42 expression: "lines.iter().map(|line| format!(\"{line:?}\")).collect::>().join(\"\\n\")" --- Line::from("Default") @@ -24,8 +23,8 @@ Line::from_iter([Span::from("Tip:").bold(), Span::from(" Press "), Span::from("c Line::from("entered prompts.") Line::default() Line::from_iter([Span::from("Tip:").bold(), Span::from(" For models with adjustable")]) -Line::from_iter([Span::from("reasoning, press "), Span::from("⌥+.").fg(Color::Rgb(166, 227, 161)), Span::from(" to increase")]) -Line::from_iter([Span::from("reasoning effort or "), Span::from("⌥+,").fg(Color::Rgb(166, 227, 161)), Span::from(" to decrease it.")]) +Line::from_iter([Span::from("reasoning, press "), Span::from("⌥.").fg(Color::Rgb(166, 227, 161)), Span::from(" to increase")]) +Line::from_iter([Span::from("reasoning effort or "), Span::from("⌥,").fg(Color::Rgb(166, 227, 161)), Span::from(" to decrease it.")]) Line::default() Line::from("Remapped") Line::from_iter([Span::from("Tip:").bold(), Span::from(" Press "), Span::from("ctrl+x q").fg(Color::Rgb(166, 227, 161)), Span::from(" to queue a message")]) diff --git a/codex-rs/tui/src/transcript_view/footer_tests.rs b/codex-rs/tui/src/transcript_view/footer_tests.rs index fd42877a392b..db8e030d4aeb 100644 --- a/codex-rs/tui/src/transcript_view/footer_tests.rs +++ b/codex-rs/tui/src/transcript_view/footer_tests.rs @@ -301,7 +301,7 @@ fn loading_completion_restores_unseen_activity_and_failure_stops_motion() { let failed = view .footer(/*width*/ 80, MotionMode::Reduced) .expect("failed history footer"); - insta::assert_snapshot!(failed.text.to_string(), @"New activity · Retry history: ⌥+ ---\n\nAfter", "> —"), + ("Before\n\n> ---\n\nAfter", "—"), ] { let layout = markdown_layout(source, /*width*/ 80); let copied = payload(&layout, 0..layout.text().len()).0; @@ -461,7 +461,11 @@ fn partial_prose_keeps_leading_whitespace_outside_code_blocks() { ] { let layout = markdown_layout(source, /*width*/ 80); let start = layout.text().find("prefix").unwrap() + "prefix".len(); - let copied = payload(&layout, start..layout.text().len()).0; + let (copied, format) = payload(&layout, start..layout.text().len()); + if format == CopyFormat::PlainText { + copies.push(format!("{copied}\n{format:?}")); + continue; + } copies.push(format!( "{copied}\n{}", crate::clipboard_html::render_markdown(&copied) @@ -711,7 +715,14 @@ fn selected_list_and_code_containers_keep_their_structure() { let layout = markdown_layout(source, /*width*/ 80); let start = layout.text().find(start).expect(source); let end = layout.text().rfind(end).expect(source) + end.len(); - let copied = payload(&layout, start..end).0; + let (copied, format) = payload(&layout, start..end); + if source.starts_with('>') { + assert_eq!( + (copied.as_str(), format), + (&layout.text()[start..end], CopyFormat::PlainText) + ); + continue; + } assert_eq!( crate::clipboard_html::render_markdown(&copied), crate::clipboard_html::render_markdown(expected), diff --git a/codex-rs/tui/src/transcript_view/markdown_element_copy_tests.rs b/codex-rs/tui/src/transcript_view/markdown_element_copy_tests.rs index a22d3a824288..76028b0a8d29 100644 --- a/codex-rs/tui/src/transcript_view/markdown_element_copy_tests.rs +++ b/codex-rs/tui/src/transcript_view/markdown_element_copy_tests.rs @@ -12,7 +12,7 @@ fn supported_elements_preserve_structure_at_narrow_and_wide_widths() { "Escaped \\*stars\\*, & entity, café 界, and 👩‍💻.", "- first\n- second\n - nested\n\n3. third\n4. fourth", "- [ ] pending\n- [x] complete", - "> quoted **text**\n>\n> - item\n> - nested", + "Before\n\n> quoted **text**\n>\n> - item\n> - nested\n\nAfter", "First paragraph.\n\nHard break: \nnext line.\n\n---\n\nLast paragraph.", "Prose\n\n```\nlet x = \"*literal* \";\n```\n\nAfter", "Image ![**alt** text](https://example.com/image.png).", @@ -73,3 +73,161 @@ fn math_and_mermaid_copy_the_visible_rendering() { } } } + +#[test] +fn blockquote_selection_copies_only_selected_content() { + let mut copies = Vec::new(); + for (source, selected) in [ + ("> quoted text", "quoted text"), + ("> quoted text", "quoted"), + ("> quoted text", "text"), + ("> first line\n> second line", "first line\nsecond line"), + ( + "> first paragraph\n>\n> second paragraph", + "first paragraph\n\nsecond paragraph", + ), + ( + "> outer\n>> nested café界\n>\n> outer again", + "outer\n\nnested café界\n\nouter again", + ), + ("> **bold** and `code`", "bold and code"), + ("> **bold**\n>\n> - [x] task", "bold\n\n"), + ("> **bold**\n>\n> | A |\n> |---|\n> | cell |", "bold\n\n"), + ( + "> literal \\> marker and a > b", + "literal > marker and a > b", + ), + ("Before\n\n> quoted text\n\nAfter", "quoted text"), + ("Before\n\n> quoted text\n\nAfter", "quoted text\n"), + ("Before\n\n> quoted text\n\nAfter", "\nquoted text"), + ( + "> - first\n> continuation\n> - second", + "first\ncontinuation\n\nsecond", + ), + ] { + for width in [18, 80] { + let layout = markdown_layout(source, width); + let start = layout.text().find(selected).expect(source); + let copied = payload(&layout, start..start + selected.len()); + assert_eq!(copied, (selected.into(), CopyFormat::PlainText), "{source}"); + assert_eq!( + payload(&layout.rewrap(/*width*/ 12), start..start + selected.len()), + copied + ); + if width == 80 && copies.len() < 3 { + copies.push(format!("{source}\nSelected {selected:?} -> {copied:?}")); + } + } + } + insta::assert_snapshot!(copies.join("\n"), @r#" + > quoted text + Selected "quoted text" -> ("quoted text", PlainText) + > quoted text + Selected "quoted" -> ("quoted", PlainText) + > quoted text + Selected "text" -> ("text", PlainText) + "#); +} + +#[test] +fn quoted_tables_reconstruct_wrapped_cell_values() { + let table = "| Path |\n|---|\n| `/long/path/to/a/file/with_a_long_name.rs` |"; + for prefix in ["> ", "> > "] { + let source = table + .lines() + .map(|line| format!("{prefix}{line}")) + .collect::>() + .join("\n"); + for width in [18, 80] { + let layout = markdown_layout(&source, width); + let copied = payload(&layout, 0..layout.text().len()); + assert_eq!(copied, (table.into(), CopyFormat::Markdown)); + let start = layout.text().find("/long").unwrap(); + let end = layout.text().rfind(".rs").unwrap() + 3; + let cell = payload(&layout, start..end); + assert_eq!( + cell, + ( + "/long/path/to/a/file/with_a_long_name.rs".into(), + CopyFormat::PlainText + ) + ); + let rewrapped = layout.rewrap(/*width*/ 12); + assert_eq!(payload(&rewrapped, start..end), cell); + assert_eq!(payload(&rewrapped, 0..rewrapped.text().len()), copied); + } + } +} + +#[test] +fn quoted_task_lists_preserve_checkbox_states() { + for (source, expected) in [ + ( + "> - [x] Tests passed\n> - [ ] Deploy", + "- [x] Tests passed\n- [ ] Deploy", + ), + ( + "> > 1. [ ] Tests passed\n> > 2. [x] Deploy", + "1. [ ] Tests passed\n2. [x] Deploy", + ), + ("> - [x] \n> - ordinary", "- [x]\n- ordinary"), + ("> - ordinary\n> - [ ] ", "- ordinary\n- [ ]"), + ] { + for width in [18, 80] { + let layout = markdown_layout(source, width); + let copied = payload(&layout, 0..layout.text().len()); + assert_eq!(copied, (expected.into(), CopyFormat::Markdown)); + let rewrapped = layout.rewrap(/*width*/ 12); + assert_eq!(payload(&rewrapped, 0..rewrapped.text().len()), copied); + if width == 80 && source.starts_with("> - [x] Tests") { + insta::assert_snapshot!(copied.0, @" + - [x] Tests passed + - [ ] Deploy + "); + } + } + } +} + +#[test] +fn mouse_selected_blockquote_copies_without_markers() { + use crossterm::event::KeyModifiers; + use crossterm::event::MouseButton; + use crossterm::event::MouseEvent; + use crossterm::event::MouseEventKind; + + let cells: Vec> = vec![Arc::new(AgentMarkdownCell::new( + "> quoted text".into(), + Path::new("/"), + ))]; + let mut view = TranscriptView::default(); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 32, /*height*/ 10, + ); + view.render(area, &mut Buffer::empty(area), &cells); + for (kind, column) in [ + (MouseEventKind::Down(MouseButton::Left), 0), + (MouseEventKind::Drag(MouseButton::Left), 31), + ] { + view.handle_mouse( + MouseEvent { + kind, + column, + row: 0, + modifiers: KeyModifiers::NONE, + }, + &cells, + ); + } + let plain = view.selected_text(&cells).unwrap(); + view.copy_selected_text_with( + &cells, + &plain, + /*clear_selection*/ true, + |text, format| { + assert_eq!((text, format), ("quoted text", CopyFormat::PlainText)); + Ok(crate::clipboard_copy::CopyStatus::Confirmed) + }, + ) + .unwrap(); +} diff --git a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__compact_navigation_hints.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__compact_navigation_hints.snap index f92b0ef3fd9b..b74a9cff6f0c 100644 --- a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__compact_navigation_hints.snap +++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__footer__tests__compact_navigation_hints.snap @@ -1,17 +1,16 @@ --- source: tui/src/transcript_view/footer_tests.rs -assertion_line: 240 expression: "snapshots.join(\"\\n\")" --- 26 columns, LoadingOlder: ↑ Loading… · esc latest 26 columns, LoadingBeginning: ↑ Loading… · esc latest -26 columns, Failed: Retry: ⌥+ suffix

--- - suffix -

suffix

+ suffix +PlainText diff --git a/codex-rs/tui/src/unarchive_prompt_tests.rs b/codex-rs/tui/src/unarchive_prompt_tests.rs index 5c6df51e9490..4a7a066fe67a 100644 --- a/codex-rs/tui/src/unarchive_prompt_tests.rs +++ b/codex-rs/tui/src/unarchive_prompt_tests.rs @@ -32,6 +32,7 @@ fn render(screen: &UnarchivePrompt) -> String { fn resume_prompt_snapshot() { let screen = prompt(SessionStartAction::Resume( ResumeModelSettings::RestoreFromThread, + crate::resume_permissions::ResumePermissions::default(), )); insta::assert_snapshot!(render(&screen), @" This conversation is archived diff --git a/codex-rs/tui/tests/suite/directory_trust.rs b/codex-rs/tui/tests/suite/directory_trust.rs index b9efce788eb6..f44a19a9ee56 100644 --- a/codex-rs/tui/tests/suite/directory_trust.rs +++ b/codex-rs/tui/tests/suite/directory_trust.rs @@ -4,6 +4,7 @@ use super::focus_palette::PtyCodex; use super::focus_palette::write_test_config; use anyhow::Result; use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::RequestId; use futures::SinkExt; use futures::StreamExt; use pretty_assertions::assert_eq; @@ -19,7 +20,10 @@ use tokio_tungstenite::tungstenite::Message; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn connected_trust_cancellation_and_acceptance_control_task_creation() -> Result<()> { for trust_level in [None, Some("untrusted")] { - let repo_root = codex_utils_cargo_bin::repo_root()?; + let workspace = tempfile::tempdir()?; + let repo_root = workspace.path().canonicalize()?; + std::fs::create_dir(repo_root.join(".git"))?; + std::fs::write(repo_root.join(".git/HEAD"), "ref: refs/heads/main\n")?; let codex_home = tempfile::tempdir_in("/tmp")?; // The server's trust decision must win over the client's trusted-folder setting. write_test_config(codex_home.path(), &repo_root)?; @@ -80,11 +84,7 @@ async fn connected_trust_cancellation_and_acceptance_control_task_creation() -> json!({"account": {"type": "apiKey"}, "requiresOpenaiAuth": false}) } "config/read" => { - if request - .params - .as_ref() - .is_some_and(|params| params["includeLayers"] == true) - { + if matches!(&request.id, RequestId::String(id) if id.starts_with("tui-project-trust-read-")) { trust_reads.fetch_add(1, Ordering::SeqCst); } json!({"config": {"model": "gpt-5.6-terra", "projects": { diff --git a/codex-rs/tui/tests/suite/mod.rs b/codex-rs/tui/tests/suite/mod.rs index e827d70b614d..5d88a0be9b93 100644 --- a/codex-rs/tui/tests/suite/mod.rs +++ b/codex-rs/tui/tests/suite/mod.rs @@ -6,6 +6,8 @@ mod directory_trust; #[cfg(unix)] mod focus_palette; #[cfg(unix)] +mod provider_defaults; +#[cfg(unix)] mod reconnect; mod resize_reflow; #[cfg(unix)] diff --git a/codex-rs/tui/tests/suite/provider_defaults.rs b/codex-rs/tui/tests/suite/provider_defaults.rs new file mode 100644 index 000000000000..3ae9c8532ee7 --- /dev/null +++ b/codex-rs/tui/tests/suite/provider_defaults.rs @@ -0,0 +1,111 @@ +//! Resume and fork picker and --last provider defaults through the real CLI and app server. + +use super::focus_palette::PtyCodex; +use super::focus_palette::write_test_config; +use anyhow::Result; +use anyhow::ensure; +use app_test_support::create_fake_rollout; +use std::process::Stdio; +use std::time::Duration; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn history_lookup_uses_server_provider_with_local_and_embedded_servers() -> Result<()> { + let codex = codex_utils_cargo_bin::cargo_bin("codex")?; + let cwd = codex_utils_cargo_bin::repo_root()?; + for action in ["resume", "fork"] { + for (embedded, last, explicit) in [ + (false, false, false), + (true, false, false), + (false, true, false), + (true, true, false), + (false, true, true), + (false, false, true), + ] { + let home = tempfile::tempdir_in("/tmp")?; + write_test_config(home.path(), &cwd)?; + let config_path = home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + config_path, + format!( + "{config}\n[model_providers.server-provider]\nname = 'Server provider'\nbase_url = 'http://127.0.0.1:9/v1'\nwire_api = 'responses'\nrequires_openai_auth = false\n[projects.'/']\ntrust_level = 'trusted'\n" + ), + )?; + for (hour, provider, preview) in [ + ("10", "openai", "Client provider history"), + ("11", "server-provider", "Server provider history"), + ] { + create_fake_rollout( + home.path(), + &format!("2025-01-02T{hour}-00-00"), + &format!("2025-01-02T{hour}:00:00Z"), + preview, + Some(provider), + /*git_info*/ None, + )?; + } + let mut daemon = tokio::process::Command::new(&codex) + .args([ + "app-server", + "--listen", + "unix://", + "-c", + "model_provider=\"server-provider\"", + ]) + .env("CODEX_HOME", home.path()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .kill_on_drop(true) + .spawn()?; + let socket = codex_app_server_client::app_server_control_socket_path(home.path())?; + tokio::time::timeout(Duration::from_secs(/*secs*/ 30), async { + loop { + if tokio::net::UnixStream::connect(socket.as_path()) + .await + .is_ok() + { + return Ok::<(), anyhow::Error>(()); + } + ensure!( + daemon.try_wait()?.is_none(), + "app server exited before opening its socket" + ); + tokio::time::sleep(Duration::from_millis(/*millis*/ 20)).await; + } + }) + .await??; + let remote_endpoint = format!("unix://{}", socket.as_path().display()); + let mut args = vec![action, "--all"]; + if embedded { + args.push("--no-daemon"); + } + if last { + args.push("--last"); + } + if explicit { + args.extend([ + "--remote", + remote_endpoint.as_str(), + "-c", + "model_provider=\"openai\"", + ]); + } + let mut terminal = PtyCodex::start_cli(&cwd, home, &args)?; + let (expected, excluded) = if embedded || explicit { + ("Client provider history", "Server provider history") + } else { + ("Server provider history", "Client provider history") + }; + terminal.wait_for_screen(expected)?; + ensure!( + !terminal.screen_contains(excluded), + "{action}: unexpected provider history" + ); + drop(terminal); + daemon.kill().await?; + daemon.wait().await?; + } + } + Ok(()) +} diff --git a/codex-rs/utils/process/BUILD.bazel b/codex-rs/utils/process/BUILD.bazel new file mode 100644 index 000000000000..c089719d4e25 --- /dev/null +++ b/codex-rs/utils/process/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "process", + crate_name = "codex_utils_process", +) diff --git a/codex-rs/utils/process/Cargo.toml b/codex-rs/utils/process/Cargo.toml new file mode 100644 index 000000000000..3d5bbd66ad92 --- /dev/null +++ b/codex-rs/utils/process/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "codex-utils-process" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dev-dependencies] +pretty_assertions = { workspace = true } +tokio = { workspace = true, features = ["macros", "process", "rt-multi-thread"] } + +[lib] +doctest = false diff --git a/codex-rs/utils/process/src/background_command_tests.rs b/codex-rs/utils/process/src/background_command_tests.rs new file mode 100644 index 000000000000..216b3279c2fb --- /dev/null +++ b/codex-rs/utils/process/src/background_command_tests.rs @@ -0,0 +1,54 @@ +//! Verifies detached background children stay console-free through std and Tokio launches. + +use super::background_command; +use pretty_assertions::assert_eq; +use std::os::windows::process::CommandExt; +use std::process::Command; + +#[tokio::test] +async fn background_commands_have_no_console_under_detached_parent() { + const TEST_NAME: &str = "tests::background_commands_have_no_console_under_detached_parent"; + const PHASE: &str = "CODEX_TEST_BACKGROUND_COMMAND_PHASE"; + let executable = std::env::current_exe().expect("test executable"); + match std::env::var(PHASE).as_deref() { + Ok("probe") => { + #[link(name = "kernel32")] + unsafe extern "system" { + #[link_name = "GetConsoleWindow"] + fn get_console_window() -> isize; + } + assert_eq!(unsafe { get_console_window() }, 0); + println!("background stdout"); + eprintln!("background stderr"); + } + Ok("detached") => { + for asynchronous in [false, true] { + let mut command = background_command(&executable); + command + .args(["--exact", TEST_NAME, "--nocapture"]) + .env(PHASE, "probe"); + let output = if asynchronous { + tokio::process::Command::from(command).output().await + } else { + command.output() + } + .expect("run background command"); + assert!(output.status.success(), "probe failed: {output:?}"); + assert!(String::from_utf8_lossy(&output.stdout).contains("background stdout")); + assert!(String::from_utf8_lossy(&output.stderr).contains("background stderr")); + } + } + _ => { + let output = Command::new(executable) + .args(["--exact", TEST_NAME, "--nocapture"]) + .env(PHASE, "detached") + .creation_flags(/*flags*/ 0x0000_0008) // DETACHED_PROCESS + .output() + .expect("spawn detached parent"); + assert!( + output.status.success(), + "detached parent failed: {output:?}" + ); + } + } +} diff --git a/codex-rs/utils/process/src/lib.rs b/codex-rs/utils/process/src/lib.rs new file mode 100644 index 000000000000..b3c449182195 --- /dev/null +++ b/codex-rs/utils/process/src/lib.rs @@ -0,0 +1,26 @@ +//! Command construction for background processes that must not allocate a Windows console. +//! Callers retain control of environment, stdio, and process lifetime. Interactive, +//! detached, and private-desktop launches must select their own console policy. + +use std::ffi::OsStr; +use std::process::Command; + +/// Construct a background command, suppressing console allocation on Windows. +/// Convert with `tokio::process::Command::from` for asynchronous execution. +/// Subsequent Windows `creation_flags` calls must retain `CREATE_NO_WINDOW`. +pub fn background_command(program: impl AsRef) -> Command { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + + let mut command = Command::new(program); + command.creation_flags(/*flags*/ 0x0800_0000); // CREATE_NO_WINDOW + command + } + #[cfg(not(windows))] + Command::new(program) +} + +#[cfg(all(test, windows))] +#[path = "background_command_tests.rs"] +mod tests; diff --git a/codex-rs/utils/pty/src/child_command.rs b/codex-rs/utils/pty/src/child_command.rs index 26d053acca27..452e3ecddec6 100644 --- a/codex-rs/utils/pty/src/child_command.rs +++ b/codex-rs/utils/pty/src/child_command.rs @@ -15,8 +15,6 @@ use std::path::Path; use std::process::Stdio as TokioStdio; #[cfg(windows)] use winapi::um::winbase::CREATE_NO_WINDOW; -#[cfg(windows)] -use winapi::um::winbase::CREATE_SUSPENDED; use crate::child::Child; use crate::child::ChildKind; @@ -240,19 +238,10 @@ impl Command { self } - /// Set the Windows process creation flags, replacing any previously selected flags. - #[cfg(windows)] - pub fn creation_flags(&mut self, flags: u32) -> &mut Self { - self.inner.creation_flags(flags); - self - } /// Preserve Job Object assignment before the child begins executing on Windows. #[cfg(windows)] pub fn prepare_suspended_spawn(&mut self, job: &crate::JobObject) { job.prepare_suspended_spawn(&mut self.inner); - // Tokio's creation_flags replaces, rather than adds to, the flags. - self.inner - .creation_flags(CREATE_NO_WINDOW | CREATE_SUSPENDED); } /// Reject original inputs that std replaced with a NUL-free placeholder. diff --git a/codex-rs/utils/pty/src/win/job.rs b/codex-rs/utils/pty/src/win/job.rs index b06035f72d22..f963c6f55fd5 100644 --- a/codex-rs/utils/pty/src/win/job.rs +++ b/codex-rs/utils/pty/src/win/job.rs @@ -14,6 +14,7 @@ use winapi::um::jobapi2::SetInformationJobObject; use winapi::um::jobapi2::TerminateJobObject; use winapi::um::processthreadsapi::OpenProcess; use winapi::um::processthreadsapi::TerminateProcess; +use winapi::um::winbase::CREATE_NO_WINDOW; use winapi::um::winbase::CREATE_SUSPENDED; use winapi::um::winnt::HANDLE; use winapi::um::winnt::JOB_OBJECT_LIMIT_BREAKAWAY_OK; @@ -121,9 +122,14 @@ impl JobObject { } } - /// Prevents a child from running before it can be assigned to this job. + /// Prepares a background child to be assigned to this job before it runs. + /// + /// Replaces all creation flags with `CREATE_SUSPENDED | CREATE_NO_WINDOW`. + /// Interactive children that need to inherit a console require an explicit launch. pub fn prepare_suspended_spawn(&self, command: &mut Command) { - command.creation_flags(CREATE_SUSPENDED).kill_on_drop(true); + command + .creation_flags(CREATE_NO_WINDOW | CREATE_SUSPENDED) + .kill_on_drop(true); } /// Assigns and resumes a suspended child, returning whether assignment succeeded. @@ -165,7 +171,29 @@ impl JobObject { } } - /// Starts a process only after assigning it to this Job Object. + /// Starts a console-free background process, falling back when containment is unavailable. + /// The returned job owns the contained process tree when assignment succeeds. + pub fn spawn_background(command: &mut Command) -> io::Result<(Child, Option)> { + Self::spawn_background_with_job(command, Self::create()) + } + + fn spawn_background_with_job( + command: &mut Command, + job: io::Result, + ) -> io::Result<(Child, Option)> { + match job.and_then(|job| job.spawn_contained(command).map(|child| (child, job))) { + Ok((child, job)) => Ok((child, Some(job))), + Err(_) => { + // Remove suspension before retrying, including after failed assignment. + command.creation_flags(CREATE_NO_WINDOW).kill_on_drop(true); + command.spawn().map(|child| (child, None)) + } + } + } + + /// Starts a background child without a console, assigning it to this job before it runs. + /// + /// Replaces existing creation flags as in [`Self::prepare_suspended_spawn`]. pub fn spawn_contained(&self, command: &mut Command) -> io::Result { self.prepare_suspended_spawn(command); let child = command.spawn()?; @@ -230,3 +258,7 @@ impl AsRawHandle for JobObject { self.handle.as_raw_handle() } } + +#[cfg(test)] +#[path = "job_tests.rs"] +mod tests; diff --git a/codex-rs/utils/pty/src/win/job_tests.rs b/codex-rs/utils/pty/src/win/job_tests.rs new file mode 100644 index 000000000000..3d430d363c74 --- /dev/null +++ b/codex-rs/utils/pty/src/win/job_tests.rs @@ -0,0 +1,87 @@ +//! Verifies console suppression through contained launches and both containment fallbacks. + +use super::*; +use pretty_assertions::assert_eq; +use std::os::windows::process::CommandExt; +use std::process::Stdio; +use std::time::Duration; +use winapi::um::winnt::JOB_OBJECT_LIMIT_ACTIVE_PROCESS; + +#[tokio::test] +async fn background_launches_keep_consoles_hidden_when_containment_fails() -> anyhow::Result<()> { + const TEST_NAME: &str = + "win::job::tests::background_launches_keep_consoles_hidden_when_containment_fails"; + const PHASE: &str = "CODEX_TEST_BACKGROUND_JOB_PHASE"; + let executable = std::env::current_exe()?; + match std::env::var(PHASE).as_deref() { + Ok("probe") => { + assert!(unsafe { winapi::um::wincon::GetConsoleWindow() }.is_null()); + println!("background probe completed"); + return Ok(()); + } + Ok("hold") => { + std::thread::sleep(Duration::from_secs(/*secs*/ 30)); + return Ok(()); + } + Ok("parent") => {} + _ => { + let output = std::process::Command::new(executable) + .args(["--exact", TEST_NAME, "--nocapture"]) + .env(PHASE, "parent") + .creation_flags(winapi::um::winbase::DETACHED_PROCESS) + .output()?; + assert!(output.status.success(), "detached probe failed: {output:?}"); + return Ok(()); + } + } + + // Fill a one-process job so assigning the next suspended child must fail. + // The parent remains outside this job, allowing the uncontained retry to run. + let full_job = JobObject::create()?; + let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() }; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_ACTIVE_PROCESS + | JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + | JOB_OBJECT_LIMIT_BREAKAWAY_OK; + limits.BasicLimitInformation.ActiveProcessLimit = 1; + let configured = unsafe { + SetInformationJobObject( + full_job.handle.as_raw_handle().cast(), + JobObjectExtendedLimitInformation, + std::ptr::addr_of_mut!(limits).cast(), + std::mem::size_of::() as u32, + ) + }; + anyhow::ensure!(configured != 0, "{}", io::Error::last_os_error()); + let mut holder_command = Command::new(&executable); + holder_command + .args(["--exact", TEST_NAME]) + .env(PHASE, "hold"); + let mut holder = full_job.spawn_contained(&mut holder_command)?; + + for (case, job, expected_containment) in [ + ( + "creation failure", + Err(io::Error::other("forced creation failure")), + false, + ), + ("assignment failure", Ok(full_job), false), + ("contained", JobObject::create(), true), + ] { + let mut command = Command::new(&executable); + command + .args(["--exact", TEST_NAME, "--nocapture"]) + .env(PHASE, "probe") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let (child, job) = JobObject::spawn_background_with_job(&mut command, job)?; + assert_eq!(job.is_some(), expected_containment, "{case}"); + let output = + tokio::time::timeout(Duration::from_secs(/*secs*/ 10), child.wait_with_output()) + .await??; + assert!(output.status.success(), "{case}: {output:?}"); + assert!(String::from_utf8_lossy(&output.stdout).contains("background probe completed")); + } + tokio::time::timeout(Duration::from_secs(/*secs*/ 10), holder.wait()).await??; + Ok(()) +} diff --git a/codex-rs/windows-sandbox-rs/Cargo.toml b/codex-rs/windows-sandbox-rs/Cargo.toml index b182ed8c9ecd..65332f3506bf 100644 --- a/codex-rs/windows-sandbox-rs/Cargo.toml +++ b/codex-rs/windows-sandbox-rs/Cargo.toml @@ -26,6 +26,7 @@ path = "src/bin/managed_deny_probe/main.rs" workspace = true [dependencies] +codex-utils-process = { workspace = true } codex-http-client = { workspace = true } anyhow = "1.0" base64 = { workspace = true } diff --git a/codex-rs/windows-sandbox-rs/src/bin/command_runner/win/cwd_junction.rs b/codex-rs/windows-sandbox-rs/src/bin/command_runner/win/cwd_junction.rs index 7ddd391c3498..9243cc67aa76 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/command_runner/win/cwd_junction.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/command_runner/win/cwd_junction.rs @@ -100,7 +100,7 @@ pub fn create_cwd_junction(requested_cwd: &Path, log_dir: Option<&Path>) -> Opti &format!("junction: creating via cmd /c mklink /J {link_quoted} {target_quoted}"), log_dir, ); - let output = match std::process::Command::new("cmd") + let output = match codex_utils_process::background_command("cmd") .raw_arg("/c") .raw_arg("mklink") .raw_arg("/J") diff --git a/codex-rs/windows-sandbox-rs/src/deny_read_resolver.rs b/codex-rs/windows-sandbox-rs/src/deny_read_resolver.rs index f7b39839f919..ed11fb14a36e 100644 --- a/codex-rs/windows-sandbox-rs/src/deny_read_resolver.rs +++ b/codex-rs/windows-sandbox-rs/src/deny_read_resolver.rs @@ -7,7 +7,6 @@ use codex_protocol::permissions::windows_deny_read_glob_scan; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashSet; use std::path::PathBuf; -use std::process::Command; #[path = "deny_read_walker.rs"] mod walker; @@ -100,7 +99,7 @@ pub fn resolve_windows_deny_read_paths( } fn ripgrep_files(scan_plan: &GlobScanPlan) -> Result>, String> { - let mut command = Command::new("rg"); + let mut command = codex_utils_process::background_command("rg"); command .arg("--files") .arg("--hidden") diff --git a/codex-rs/windows-sandbox-rs/src/setup.rs b/codex-rs/windows-sandbox-rs/src/setup.rs index c40a56c996a2..484f28671324 100644 --- a/codex-rs/windows-sandbox-rs/src/setup.rs +++ b/codex-rs/windows-sandbox-rs/src/setup.rs @@ -386,7 +386,7 @@ fn run_setup_refresh_payload(b64: &str, codex_home: &Path) -> Result<()> { } }; // Refresh should never request elevation; ensure verb isn't set and we don't trigger UAC. - let mut cmd = Command::new(&exe); + let mut cmd = codex_utils_process::background_command(&exe); crate::launch_environment::configure_command(&mut cmd, b64)?; cmd.stdin(Stdio::null()) .stdout(Stdio::null()) @@ -926,6 +926,10 @@ fn verify_setup_completed(codex_home: &Path) -> Result<()> { } } +#[cfg(test)] +#[path = "setup_refresh_tests.rs"] +mod refresh_tests; + fn run_setup_exe( payload: &ElevationPayload, needs_elevation: bool, diff --git a/codex-rs/windows-sandbox-rs/src/setup_refresh_tests.rs b/codex-rs/windows-sandbox-rs/src/setup_refresh_tests.rs new file mode 100644 index 000000000000..0a9f5d5315e8 --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/setup_refresh_tests.rs @@ -0,0 +1,64 @@ +//! Verifies refresh helpers stay console-free when launched by a detached daemon. + +use std::fs; +use std::os::windows::process::CommandExt; +use std::path::PathBuf; +use std::process::Command; + +use anyhow::Result; +use pretty_assertions::assert_eq; +use windows_sys::Win32::System::Console::GetConsoleWindow; +use windows_sys::Win32::System::Threading::DETACHED_PROCESS; + +use super::run_setup_refresh_payload; + +const DETACHED_TEST: &str = "setup::refresh_tests::refresh_helper_has_no_console"; +const HELPER_TEST: &str = "setup::refresh_tests::refresh_console_probe"; +const HOME_ENV: &str = "CODEX_TEST_REFRESH_CONSOLE_HOME"; + +#[test] +fn refresh_helper_has_no_console() -> Result<()> { + if let Some(home) = std::env::var_os(HOME_ENV).map(PathBuf::from) { + assert_eq!(unsafe { GetConsoleWindow() }, 0); + run_setup_refresh_payload(HELPER_TEST, &home)?; + assert_eq!(fs::read(home.join("probe"))?, b"no console"); + return Ok(()); + } + + // Re-execute detached with a disposable fake setup helper beside it. This + // exercises the production refresh launch without provisioning sandbox users. + let temporary = tempfile::tempdir()?; + let executable = std::env::current_exe()?; + let detached = temporary.path().join("detached.exe"); + fs::copy(&executable, &detached)?; + fs::copy( + &executable, + temporary.path().join("codex-windows-sandbox-setup.exe"), + )?; + let output = Command::new(detached) + .args(["--exact", DETACHED_TEST, "--nocapture"]) + .env(HOME_ENV, temporary.path()) + .creation_flags(DETACHED_PROCESS) + .output()?; + assert!( + output.status.success(), + "detached refresh probe failed:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + assert_eq!(fs::read(temporary.path().join("probe"))?, b"no console"); + Ok(()) +} + +#[test] +fn refresh_console_probe() -> Result<()> { + // The setup launcher supplies this test name as its single payload argument. + // Ordinary test runs must not require their own parent to be console-free. + if std::env::args().nth(1).as_deref() != Some(HELPER_TEST) { + return Ok(()); + } + let home = PathBuf::from(std::env::var_os(HOME_ENV).expect("detached probe home")); + assert_eq!(unsafe { GetConsoleWindow() }, 0); + fs::write(home.join("probe"), b"no console")?; + Ok(()) +}