From 13f580ef090887bf0dfcdd366fa783ac553e273e Mon Sep 17 00:00:00 2001 From: Anthony Tafoya Date: Tue, 29 Sep 2026 01:48:11 +0000 Subject: [PATCH 01/13] Deduplicate cloud and executor skill listings before budgeting (#49127) ## Why Skills available from both cloud and executor providers consume duplicate catalog space, leaving less room for descriptions of unique skills. ## What changed - Prefer model-visible cloud skills over executor listings with the same `plugin:skill` name before allocating the metadata budget. - Preserve aliases from the full visible executor inventory so filtering duplicates does not renumber remaining packages. Continue choosing full locators when they fit the budget better. - Keep executor packages available through `skills.list` and `skills.read`, and retain executor listings when cloud skills are disabled. ## Testing Add regression tests and a model-request snapshot covering description retention, stable aliases across turns, executor availability changes, package reads, and full-locator selection after deduplication. GitOrigin-RevId: 477e4cf16b8968dbe61438dd5af1eca9cbfa78c8 --- codex-rs/core/tests/suite/scenarios.rs | 3 + .../suite/scenarios_skill_catalog_dedup.rs | 151 +++++++++++ codex-rs/core/tests/suite/skills_extension.rs | 4 +- ...catalog_dedup__dedup_before_budgeting.snap | 125 +++++++++ codex-rs/ext/skills/src/render.rs | 143 +++-------- codex-rs/ext/skills/src/render_dedup.rs | 103 ++++++++ codex-rs/ext/skills/src/render_tests.rs | 74 ++++++ .../ext/skills/src/world_state_catalogs.rs | 53 ++-- codex-rs/ext/skills/tests/skills_extension.rs | 3 + .../presentation_dedup_tests.rs | 239 ++++++++++++++++++ 10 files changed, 776 insertions(+), 122 deletions(-) create mode 100644 codex-rs/core/tests/suite/scenarios_skill_catalog_dedup.rs create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__scenarios__skill_catalog_dedup__dedup_before_budgeting.snap create mode 100644 codex-rs/ext/skills/src/render_dedup.rs create mode 100644 codex-rs/ext/skills/tests/skills_extension/presentation_dedup_tests.rs 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/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(()) +} From a9118edae8b77bf23b7182fd071a7b6251898bed Mon Sep 17 00:00:00 2001 From: Won Park Date: Tue, 29 Sep 2026 01:53:24 +0000 Subject: [PATCH 02/13] Move content-filter guidance into the shared Responses retry handler (#49130) ## What changed Move content-filter guidance recording from the sampling loop into `handle_response_stream_error`. Pass `StepContext` so the handler can resolve model-specific guidance and record it before retry decisions for sampling requests. Keep remote compaction excluded from guidance recording and update callers and the existing retry-deadline test to use the new signature. GitOrigin-RevId: 030bde1d4f1597fc4f3971ad837478e5304c5aa9 --- codex-rs/core/src/compact_remote_v2.rs | 2 +- codex-rs/core/src/responses_retry.rs | 23 +++++++++++++- codex-rs/core/src/responses_retry_tests.rs | 5 ++- codex-rs/core/src/session/turn.rs | 37 ++++++---------------- 4 files changed, 36 insertions(+), 31 deletions(-) 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/turn.rs b/codex-rs/core/src/session/turn.rs index 53a37b7729c0..3e5836a29096 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; @@ -1698,33 +1697,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?; From 458f7046a52232f5c01dd8bc65dfbce8d5e79fa8 Mon Sep 17 00:00:00 2001 From: andrewgu-oai Date: Tue, 29 Sep 2026 02:15:00 +0000 Subject: [PATCH 03/13] Treat explicit provider model catalogs as authoritative (#49135) ## Why Providers with `model_catalog_url` could advertise bundled models absent from their catalog or reuse stale models after a failed refresh. Model metadata could also match by prefix or namespaced suffix instead of the provider's exact model ID. ## What changed - Suppress bundled model fallback and merging for explicit provider catalogs, including when discovery is disabled. - Require unique, non-empty catalog slugs and match metadata by exact model ID. - Serialize explicit catalog refreshes and invalidate in-memory and persisted catalog entries after fetch failures, preventing stale cache reuse. - Report a clear configuration error when startup has no available model, while allowing an explicitly configured `model`. ## Testing Add regression coverage for exact model matching, hidden catalog entries, cache invalidation across restart and recovery, invalid slugs, and empty-catalog startup with and without an explicit model. Update app-server model-list expectations and verify the TUI configuration error. GitOrigin-RevId: ac323a94178e3c48df8d4a159a86fdbac20b03a2 --- .../app-server/tests/suite/v2/model_list.rs | 10 +- codex-rs/core/src/session/mod.rs | 5 + codex-rs/core/tests/suite/remote_models.rs | 46 ++++++ .../model-provider/src/models_endpoint.rs | 56 ++++++- codex-rs/model-provider/src/provider.rs | 96 ++++++------ .../src/api_key_discovery_tests.rs | 7 +- codex-rs/models-manager/src/cache.rs | 3 + codex-rs/models-manager/src/manager.rs | 138 ++++++++++++++++-- codex-rs/models-manager/src/manager_tests.rs | 124 +++++++++++++--- codex-rs/tui/src/app_server_session.rs | 21 ++- 10 files changed, 409 insertions(+), 97 deletions(-) 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/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/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/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/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 01b3d347c733..286acd3af63a 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -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(); @@ -2580,6 +2580,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()), From d7f7fa410698fbbb75548cde553a4fbf43fc268e Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Tue, 29 Sep 2026 02:17:37 +0000 Subject: [PATCH 04/13] Remove the plus separator after Option symbols in TUI key hints (#49136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed Render macOS Option shortcuts as `⌥t` and `ctrl+⌥t` instead of `⌥+t` and `ctrl+⌥+t`. Keep `+` separators after text modifier labels such as `ctrl`, `shift`, and `alt`. ## Testing Update key-label and chord assertions and UI snapshots to expect the compact labels across shortcut help, navigation hints, and tooltips. GitOrigin-RevId: 7d52088ebce3dfa3f8bdad65eb159ca0fead8a31 --- ...__tests__owned_history_loading_footer.snap | 6 ++-- .../app/tests/session_lifecycle_requests.rs | 2 +- ...les_without_restarting_their_deadline.snap | 32 ++++++++--------- ...kle_but_literal_question_marks_do_not.snap | 16 ++++----- ...shortcut_help_follows_runtime_binding.snap | 36 +++++++++---------- ...ce__tests__shortcut_help_above_narrow.snap | 16 ++++----- ...face__tests__shortcut_help_above_wide.snap | 12 +++---- ...mposer__tests__agents_navigation_help.snap | 16 ++++----- ...ts__footer_mode_configured_key_chords.snap | 16 ++++----- ...__tests__footer_mode_shortcut_overlay.snap | 16 ++++----- ...de_shortcut_overlay_queue_submissions.snap | 16 ++++----- ...shortcuts_collaboration_modes_enabled.snap | 16 ++++----- ...oter__tests__footer_shortcuts_running.snap | 16 ++++----- ...tests__footer_shortcuts_shift_and_esc.snap | 16 ++++----- ...ests__shortcut_overlay_customized_wsl.snap | 16 ++++----- ...estions_tests__question_queue_hint_Up.snap | 2 +- codex-rs/tui/src/key_hint.rs | 4 ++- codex-rs/tui/src/key_hint_label_tests.rs | 4 +-- codex-rs/tui/src/keymap/chords_tests.rs | 4 +-- ...ript_overlay_paginated_history_states.snap | 3 +- ...ay_paginated_history_states_non_macos.snap | 2 +- ..._app__tests__path_backed_agent_picker.snap | 2 +- ...ked_thread_notifications_agent_picker.snap | 2 +- ..._shortcut_tips_render_at_narrow_width.snap | 5 ++- .../tui/src/transcript_view/footer_tests.rs | 2 +- ...oter__tests__compact_navigation_hints.snap | 7 ++-- 26 files changed, 142 insertions(+), 143 deletions(-) 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<()> { .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] 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/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/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/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: ⌥+>().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: ⌥+ Date: Tue, 29 Sep 2026 02:22:42 +0000 Subject: [PATCH 05/13] Expose original error details to turn lifecycle contributors (#49138) ## Why Turn error hooks receive only a `CodexErrorInfo` category, which omits backend metadata such as usage-limit reset times and rate-limit snapshots. ## What changed Add borrowed `CodexErrorDetails` to `TurnErrorInput` and pass the original details through turn and compaction error notifications, alongside the existing error category. ## Testing Add coverage for usage-limit metadata during sampling, manual compaction, and post-turn compaction, including preservation of completed answers and existing error events. Extend lifecycle tests to verify that an `InvalidImageRequest` retains its details when surfaced as `BadRequest`. GitOrigin-RevId: 6f2058992126da0f69508ef51998e7581557deea --- codex-rs/core/src/session/tests.rs | 28 ++- codex-rs/core/src/session/turn.rs | 28 ++- codex-rs/core/src/tasks/compact.rs | 4 +- codex-rs/core/src/tasks/lifecycle.rs | 3 + codex-rs/core/src/tasks/mod.rs | 1 + codex-rs/core/tests/suite/mod.rs | 2 + .../tests/suite/turn_error_details_tests.rs | 182 ++++++++++++++++++ .../src/contributors/turn_lifecycle.rs | 3 + .../ext/goal/tests/goal_extension_backend.rs | 11 +- 9 files changed, 245 insertions(+), 17 deletions(-) create mode 100644 codex-rs/core/tests/suite/turn_error_details_tests.rs 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 3e5836a29096..5b335c2c0572 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -204,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. @@ -629,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 { @@ -739,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"); } @@ -793,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." @@ -813,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)); 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/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/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, From ff3c82c8a9d0195f2cc92f6ac0492fd972d7acdd Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 29 Sep 2026 03:13:42 +0000 Subject: [PATCH 06/13] Preserve server reasoning summary and verbosity settings in the TUI (#49144) ## Why Forwarding local defaults as request overrides can replace server defaults or saved thread settings for reasoning summaries and verbosity. ## What changed Only forward `model_reasoning_summary` and `model_verbosity` as shared thread request overrides when their winning configuration layer is a session flag or an explicitly selected user profile. Use the effective values from that layer, and omit values whose winning source is ordinary user or project configuration. Preserve the existing reasoning-summary defaults for new embedded threads. ## Testing Add coverage for profile, project, and CLI precedence. Extend outbound model request tests to verify that remote-mode thread start and resume preserve server reasoning summary and verbosity settings despite conflicting local values. GitOrigin-RevId: 5e14254a51ed7506118a46847bb7466ab58908ef --- codex-rs/tui/src/app_server_session.rs | 111 ++++++++--- .../reasoning_defaults_tests.rs | 181 +++++++++++------- 2 files changed, 197 insertions(+), 95 deletions(-) diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 286acd3af63a..dc90d8e373f5 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1836,18 +1836,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 +1844,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()); } @@ -3381,12 +3386,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}), @@ -3396,6 +3400,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"); @@ -3418,16 +3487,10 @@ mod tests { 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()), + )])) ); } 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..9813f2b9a124 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,106 @@ stream_max_retries = 0 "# ), )?; - let config = ConfigBuilder::default() + 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(()) From 8d48f71922dcb8b8cb17a9aef63b4a610538a617 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 29 Sep 2026 03:14:17 +0000 Subject: [PATCH 07/13] Hide reasoning summary settings in `/status` for server connections (#49145) ## What changed Omit the reasoning summaries setting from the model details when connected to a remote server or local background server. Continue displaying it when no server connection is present. ## Testing Expand status snapshots to cover no server connection, a remote server, and a local background server, including the reasoning summary setting visibility in each case. GitOrigin-RevId: f7213e7ad7439107705461ccf4e070501729113b --- codex-rs/tui/src/status/card.rs | 16 ++++++++------ ...atus_snapshot_local_background_server.snap | 2 +- ..._tests__status_snapshot_remote_server.snap | 22 +++++++++++++++++++ ...s_default_reasoning_when_config_empty.snap | 3 --- codex-rs/tui/src/status/tests.rs | 11 +++++----- 5 files changed, 38 insertions(+), 16 deletions(-) create mode 100644 codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_remote_server.snap 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, From 3a16c0b7076944bd7c850950464aa38f5620407c Mon Sep 17 00:00:00 2001 From: akira-oai Date: Tue, 29 Sep 2026 03:19:26 +0000 Subject: [PATCH 08/13] Simplify cloud task base URL normalization (#49147) Replace the trailing-slash removal loop in `normalize_base_url` with `trim_end_matches('/')`, preserving existing behavior. Add regression coverage for unchanged URLs, single and repeated trailing slashes, empty and slash-only inputs, and ChatGPT URLs with and without `/backend-api`. GitOrigin-RevId: db71e80c4dcaac33b308a45684b14e7edbd71528 --- codex-rs/cloud-tasks/src/util.rs | 38 ++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) 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:?}"); + } + } +} From c6c7c8d2706981bfc2368bc896e4d07876300c3b Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Tue, 29 Sep 2026 03:41:36 +0000 Subject: [PATCH 09/13] Omit blockquote markers when copying quoted selections in the TUI (#49153) ## Why Copying text selected within a blockquote adds Markdown quote markers that are not part of the selected content. ## What changed Return plain text for selections containing only quoted content, preserving literal `>` characters and selected whitespace. For quoted tables and task lists, remove quote prefixes while retaining table reconstruction and checkbox states. Selections spanning quoted and unquoted content continue to preserve Markdown structure. Preserve the existing logical source when marking list separator lines as omitted from Markdown copying. ## Testing Add coverage for partial and nested blockquote selections, quoted tables and task lists at narrow and wide widths and after rewrapping, and copying a mouse-selected blockquote. GitOrigin-RevId: 5ac0c8c47a77937e5a35c376d8908065217ad2dc --- codex-rs/tui/src/markdown_copy.rs | 49 ++++++ codex-rs/tui/src/markdown_render.rs | 5 +- .../transcript_view/markdown_copy_tests.rs | 17 +- .../markdown_element_copy_tests.rs | 160 +++++++++++++++++- ...eading_whitespace_outside_code_blocks.snap | 5 +- 5 files changed, 227 insertions(+), 9 deletions(-) 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/transcript_view/markdown_copy_tests.rs b/codex-rs/tui/src/transcript_view/markdown_copy_tests.rs index 443afe61d038..6d7d5304a0f9 100644 --- a/codex-rs/tui/src/transcript_view/markdown_copy_tests.rs +++ b/codex-rs/tui/src/transcript_view/markdown_copy_tests.rs @@ -423,7 +423,7 @@ fn complete_rules_keep_structure_and_partial_rules_keep_selected_text() { let mut copies = Vec::new(); for (source, partial) in [ ("Before\n\n---\n\nAfter", "—"), - ("Before\n\n> ---\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__markdown_copy_tests__partial_prose_keeps_leading_whitespace_outside_code_blocks.snap b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__markdown_copy_tests__partial_prose_keeps_leading_whitespace_outside_code_blocks.snap index 720dc00cdd16..01da63c64216 100644 --- a/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__markdown_copy_tests__partial_prose_keeps_leading_whitespace_outside_code_blocks.snap +++ b/codex-rs/tui/src/transcript_view/snapshots/codex_tui__transcript_view__markdown_copy_tests__partial_prose_keeps_leading_whitespace_outside_code_blocks.snap @@ -1,6 +1,5 @@ --- source: tui/src/transcript_view/markdown_copy_tests.rs -assertion_line: 379 expression: "copies.join(\"\\n---\\n\")" --- suffix @@ -15,5 +14,5 @@ expression: "copies.join(\"\\n---\\n\")"

suffix

--- - suffix -

suffix

+ suffix +PlainText From 0d7b8117d3cc6d9ffe21d22375d70307133e9f16 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 29 Sep 2026 04:02:06 +0000 Subject: [PATCH 10/13] Support projectless TUI sessions with workspace defaults (#49160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed - Skip folder trust prompts for locally discovered projectless directories without a saved trust decision. Apply workspace-write permissions and granular approval defaults only when local execution, configuration, and managed policy allow them. - Prompt for Windows sandbox setup when needed before enabling implicit workspace-write permissions. - Restore saved task permissions on resume unless explicitly overridden, and keep ordinary turns from overwriting the server's saved permission profile. Track explicit approval reviewer choices separately from restored permissions. - Let `/cd` request folder trust with a “Keep current directory” option. Recheck task activity and background terminals before switching, and enforce destination permission requirements. ## Testing Add regression coverage for projectless defaults and session transitions, explicit permission overrides, saved profile preservation, trust discovery, destination policy conflicts, and the directory consent and Windows sandbox prompts. GitOrigin-RevId: cf74f5a90245f2aee96caa80d76de64da6ec1ebd --- codex-rs/cli/tests/doctor_path_safety.rs | 1 + codex-rs/config/src/loader/mod.rs | 3 +- codex-rs/tui/src/app.rs | 4 +- codex-rs/tui/src/app/agents_overview.rs | 84 ++-- codex-rs/tui/src/app/agents_overview_tests.rs | 82 ++++ codex-rs/tui/src/app/config_persistence.rs | 113 ++++-- codex-rs/tui/src/app/event_dispatch.rs | 5 +- .../tui/src/app/managed_worktree_creation.rs | 46 ++- codex-rs/tui/src/app/misalignment_policy.rs | 12 +- codex-rs/tui/src/app/new_session.rs | 13 +- codex-rs/tui/src/app/realtime_settings.rs | 4 +- codex-rs/tui/src/app/reconnect.rs | 16 +- codex-rs/tui/src/app/resume_config.rs | 23 +- codex-rs/tui/src/app/session_lifecycle.rs | 38 +- codex-rs/tui/src/app/startup.rs | 51 ++- codex-rs/tui/src/app/test_support.rs | 1 + codex-rs/tui/src/app/tests.rs | 83 ++-- .../src/app/tests/active_reconnect_tests.rs | 12 +- .../tests/background_task_defaults_tests.rs | 106 +++--- .../tui/src/app/tests/disconnect_tests.rs | 1 + .../src/app/tests/history_hydration_tests.rs | 3 +- .../app/tests/misalignment_policy_tests.rs | 14 +- .../app/tests/navigation_reconnect_tests.rs | 1 + .../tui/src/app/tests/new_session_tests.rs | 6 +- .../app/tests/permission_selection_tests.rs | 1 + .../tui/src/app/tests/projectless_tests.rs | 358 ++++++++++++++++++ .../app/tests/session_lifecycle_requests.rs | 112 +++++- .../src/app/tests/startup_defaults_tests.rs | 51 ++- codex-rs/tui/src/app/thread_routing.rs | 47 ++- codex-rs/tui/src/app/working_directory.rs | 247 ++++++++---- codex-rs/tui/src/app_server_session.rs | 43 ++- .../prompt_history_tests.rs | 6 + .../reasoning_defaults_tests.rs | 6 + .../src/app_server_session/rollout_history.rs | 19 + .../rollout_history_tests.rs | 87 +++-- .../workspace_roots_tests.rs | 1 + ...ts__projectless_windows_sandbox_setup.snap | 13 + .../tui/src/chatwidget/tests/permissions.rs | 38 +- .../src/chatwidget/windows_sandbox_prompts.rs | 12 +- codex-rs/tui/src/config_update.rs | 42 +- codex-rs/tui/src/config_update_tests.rs | 59 ++- codex-rs/tui/src/lib.rs | 79 +--- .../tui/src/onboarding/directory_trust.rs | 55 ++- codex-rs/tui/src/onboarding/mod.rs | 2 + .../tui/src/onboarding/onboarding_screen.rs | 1 + ...ge_consent_can_keep_current_directory.snap | 18 + .../tui/src/onboarding/trust_directory.rs | 17 + codex-rs/tui/src/projectless.rs | 110 ++++++ codex-rs/tui/src/resume_permissions.rs | 57 +++ codex-rs/tui/src/resume_permissions_tests.rs | 63 +++ codex-rs/tui/src/session_start.rs | 17 +- codex-rs/tui/src/session_start_tests.rs | 12 +- ...ts__agents_overview_reviewer_conflict.snap | 12 + codex-rs/tui/src/unarchive_prompt_tests.rs | 1 + codex-rs/tui/tests/suite/directory_trust.rs | 12 +- 55 files changed, 1814 insertions(+), 506 deletions(-) create mode 100644 codex-rs/tui/src/app/tests/projectless_tests.rs create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__projectless_windows_sandbox_setup.snap create mode 100644 codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__trust_directory__tests__directory_change_consent_can_keep_current_directory.snap create mode 100644 codex-rs/tui/src/projectless.rs create mode 100644 codex-rs/tui/src/resume_permissions.rs create mode 100644 codex-rs/tui/src/resume_permissions_tests.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_overview_reviewer_conflict.snap 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/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/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..a3af86385d3a 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -2462,6 +2462,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 +2476,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..e362bb20a46d 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -324,17 +324,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 +383,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/startup.rs b/codex-rs/tui/src/app/startup.rs index f9da39c26c9d..7799eb5664b3 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -44,12 +44,19 @@ fn spawn_startup_thread_start( }); } +#[derive(Default)] +pub(super) struct FreshStartupDefaults { + pub(super) server_defaults_read: bool, + pub(super) prompt_windows_sandbox: bool, +} + pub(super) async fn prepare_fresh_startup_config( config: &mut Config, app_server: &AppServerSession, cli_kv_overrides: &[(String, TomlValue)], harness_overrides: &ConfigOverrides, -) -> 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 +68,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 +90,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( @@ -242,7 +260,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 +272,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 +292,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 +505,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 +548,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 +789,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 +833,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/background_task_defaults_tests.rs b/codex-rs/tui/src/app/tests/background_task_defaults_tests.rs index f9b572d5e836..16e4f8d56aa2 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"}, @@ -363,11 +374,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!( @@ -470,6 +481,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 +542,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 +561,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 +994,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 +1064,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..4ab60da3c04e 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 { 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 0007879bd290..f98ff749b939 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 } @@ -1764,8 +1764,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 +1834,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"); @@ -3805,7 +3818,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 +3864,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 +3897,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 +3910,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 +3968,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 +4032,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 +4070,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 +4123,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 +4144,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(()) @@ -4679,6 +4750,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 +4783,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..d1f7ee3da873 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( @@ -285,8 +287,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 +314,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 +348,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(), @@ -360,6 +374,16 @@ async fn fresh_startup_reads_destination_and_cleared_model_uses_catalog() -> Res ) .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 +406,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 dc90d8e373f5..38f5b874e42f 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -2066,6 +2066,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 { @@ -2118,14 +2119,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 } @@ -2294,23 +2301,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()), @@ -3175,6 +3173,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, @@ -3220,6 +3219,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())); @@ -3316,6 +3316,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, @@ -3371,6 +3372,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, @@ -3481,6 +3483,7 @@ mod tests { ThreadParamsMode::Embedded, /*remote_cwd_override*/ None, ResumeModelSettings::RestoreFromThread, + crate::resume_permissions::ResumePermissions::CURRENT_CONFIG, ); assert_eq!(params.model, None); @@ -3506,6 +3509,7 @@ mod tests { ThreadParamsMode::Embedded, /*remote_cwd_override*/ None, ResumeModelSettings::PreserveExistingThread, + crate::resume_permissions::ResumePermissions::CURRENT_CONFIG, ); assert_eq!( @@ -3824,6 +3828,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(), @@ -3935,6 +3940,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(), @@ -3965,6 +3971,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..8ee74ed7e0b8 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( 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 9813f2b9a124..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 @@ -69,6 +69,12 @@ stream_max_retries = 0 "# ), )?; + 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() 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..597bc152c01d 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,6 +170,7 @@ impl AppServerSession { self.thread_params_mode(), self.remote_cwd_override.as_deref(), model_settings, + permission_overrides, ); self.thread_tool_transport() .configure_mcp(&mut params.config); 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/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/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/lib.rs b/codex-rs/tui/src/lib.rs index 8da049ef4fba..ea3cd156ab27 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; @@ -1828,7 +1830,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 +1957,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 +2046,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 +2282,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 +2343,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] @@ -3654,6 +3652,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 +3675,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 +3910,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/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/projectless.rs b/codex-rs/tui/src/projectless.rs new file mode 100644 index 000000000000..a545b66ae5a6 --- /dev/null +++ b/codex-rs/tui/src/projectless.rs @@ -0,0 +1,110 @@ +//! Select desktop-like execution defaults for positively discovered local projectless folders. +//! Saved trust, explicit choices, managed policy, and remote execution retain their own defaults. + +use crate::app_server_session::AppServerSession; +use crate::legacy_core::config::Config; +use crate::legacy_core::config::ConfigOverrides; +use codex_exec_server::EnvironmentManager; +use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; +use codex_protocol::models::PermissionProfile; +use codex_protocol::models::PermissionProfileSnapshot; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::GranularApprovalConfig; + +/// Returns whether eligible implicit permissions require Windows sandbox setup. +pub(crate) fn apply_defaults( + config: &mut Config, + overrides: &ConfigOverrides, + app_server: &AppServerSession, + environments: &EnvironmentManager, + response: &crate::config_update::EffectiveConfig, +) -> 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/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/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": { From 3226512d470d3a35f27e8b23369672bbec75e336 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 29 Sep 2026 04:03:07 +0000 Subject: [PATCH 11/13] Honor app-server provider defaults in the TUI (#49161) ## Why Client provider defaults can override the app server's configured provider and hide its sessions from resume and fork history. ## What changed - Omit implicit provider overrides from thread start, resume, and fork requests so the server can resolve its defaults. - Use the server's effective provider for named history lookup, `--last`, and resume and fork pickers, while honoring explicit CLI and selected-profile choices. - Respect managed provider requirements when resolving history filters and preserve the active session's provider when forking it. ## Testing Add app-server regression tests for provider defaults, explicit choices, configuration changes, selected profiles, and managed requirements. Add CLI coverage for resume and fork pickers and `--last` with local daemon and embedded servers, plus a picker rendering snapshot. GitOrigin-RevId: a077758b13a5e55552b52e5532ad4bebe86dce7e --- codex-rs/tui/src/app/event_dispatch.rs | 1 + codex-rs/tui/src/app/reconnect.rs | 1 + codex-rs/tui/src/app/session_picker.rs | 3 +- codex-rs/tui/src/app/startup.rs | 3 + .../src/app/tests/advanced_reasoning_tests.rs | 1 + .../tests/background_task_defaults_tests.rs | 9 +- .../tui/src/app/tests/disconnect_tests.rs | 1 + .../app/tests/session_lifecycle_requests.rs | 6 +- .../src/app/tests/startup_defaults_tests.rs | 2 + codex-rs/tui/src/app_server_session.rs | 35 ++-- .../prompt_history_tests.rs | 1 + .../app_server_session/provider_selection.rs | 65 +++++++ .../provider_selection_tests.rs | 173 ++++++++++++++++++ .../src/app_server_session/rollout_history.rs | 3 + codex-rs/tui/src/lib.rs | 51 ++---- codex-rs/tui/src/resume_picker.rs | 53 +++++- ...resume_picker_shared_provider_history.snap | 6 + codex-rs/tui/tests/suite/mod.rs | 2 + codex-rs/tui/tests/suite/provider_defaults.rs | 111 +++++++++++ 19 files changed, 468 insertions(+), 59 deletions(-) create mode 100644 codex-rs/tui/src/app_server_session/provider_selection.rs create mode 100644 codex-rs/tui/src/app_server_session/provider_selection_tests.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_shared_provider_history.snap create mode 100644 codex-rs/tui/tests/suite/provider_defaults.rs diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index a3af86385d3a..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(); diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index e362bb20a46d..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) 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/startup.rs b/codex-rs/tui/src/app/startup.rs index 7799eb5664b3..0cdd4ce30cb7 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -24,6 +24,7 @@ fn spawn_startup_thread_start( let thread_params_mode = app_server.thread_params_mode(); let remote_cwd_override = app_server.remote_cwd_override().map(Path::to_path_buf); let thread_tool_transport = app_server.thread_tool_transport(); + let model_provider_override = app_server.model_provider_override.clone(); tokio::spawn(async move { let result = crate::app_server_session::start_thread_with_request_handle( request_handle, @@ -32,6 +33,7 @@ fn spawn_startup_thread_start( thread_params_mode, remote_cwd_override, thread_tool_transport, + model_provider_override, ) .await .and_then(|started| { @@ -230,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 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 16e4f8d56aa2..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 @@ -326,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())); } @@ -396,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 { diff --git a/codex-rs/tui/src/app/tests/disconnect_tests.rs b/codex-rs/tui/src/app/tests/disconnect_tests.rs index 4ab60da3c04e..b7150740b255 100644 --- a/codex-rs/tui/src/app/tests/disconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/disconnect_tests.rs @@ -369,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/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index f98ff749b939..0fbf795370a9 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -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); @@ -3567,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:?}")}] 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 d1f7ee3da873..429265902a22 100644 --- a/codex-rs/tui/src/app/tests/startup_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/startup_defaults_tests.rs @@ -269,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}"); @@ -371,6 +372,7 @@ 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); diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 38f5b874e42f..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, @@ -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) @@ -2037,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), @@ -2096,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) @@ -2155,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), @@ -2934,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); } 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 8ee74ed7e0b8..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 @@ -70,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..1e82c35b943a --- /dev/null +++ b/codex-rs/tui/src/app_server_session/provider_selection.rs @@ -0,0 +1,65 @@ +//! 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 + .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/rollout_history.rs b/codex-rs/tui/src/app_server_session/rollout_history.rs index 597bc152c01d..f16428421ad0 100644 --- a/codex-rs/tui/src/app_server_session/rollout_history.rs +++ b/codex-rs/tui/src/app_server_session/rollout_history.rs @@ -172,6 +172,9 @@ impl AppServerSession { 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/lib.rs b/codex-rs/tui/src/lib.rs index ea3cd156ab27..d1c672d9acf4 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -808,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)) @@ -830,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, @@ -837,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, @@ -865,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, @@ -877,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, @@ -1214,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) @@ -1257,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; @@ -3207,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())) @@ -3226,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, @@ -3237,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())) @@ -3274,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, @@ -3294,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, @@ -3322,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, 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/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/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(()) +} From fbc169827e9789f9e7ba2ed97c15e2021c3312bb Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 29 Sep 2026 04:27:42 +0000 Subject: [PATCH 12/13] Suppress Windows console windows for background subprocesses (#49164) ## Why Background helpers launched from a detached Windows process can allocate console windows. Job Object launches and containment fallbacks need to preserve console suppression as well. ## What changed - Add `codex_utils_process::background_command` to set `CREATE_NO_WINDOW` on Windows, and use it across background subprocess launches, including Git, plugins, authentication, diagnostics, and sandbox helpers. - Centralize console suppression in Job Object launches. Keep `CREATE_NO_WINDOW` when retrying without containment, and clear `CREATE_SUSPENDED` so the fallback child can run. - Preserve console inheritance for interactive shell launches and interactive AWS authentication refreshes. ## Testing Add Windows regression tests covering console-free launches from detached parents through standard and Tokio commands, stdout/stderr capture, successful containment, Job Object creation and assignment failures, and sandbox setup refresh helpers. GitOrigin-RevId: b2c238fa7d4f9fbd868e5adf7d4be9958ff01e6c --- codex-rs/Cargo.lock | 19 ++++ codex-rs/Cargo.toml | 2 + codex-rs/app-server/Cargo.toml | 1 + .../feedback_doctor_report.rs | 2 +- codex-rs/cli/Cargo.toml | 1 + .../src/doctor/desktop/windows_security.rs | 2 +- codex-rs/cli/src/doctor/security.rs | 2 +- codex-rs/cli/tests/daemon_startup.rs | 13 ++- codex-rs/core-plugins/Cargo.toml | 1 + codex-rs/core-plugins/src/git_policy.rs | 2 +- .../src/marketplace_add/install.rs | 3 +- codex-rs/core-plugins/src/npm_source.rs | 3 +- codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/shell_snapshot.rs | 2 +- codex-rs/core/src/spawn.rs | 7 +- codex-rs/exec-server/Cargo.toml | 1 + codex-rs/exec-server/src/client_transport.rs | 4 +- codex-rs/exec-server/src/connection.rs | 2 +- codex-rs/git-utils/Cargo.toml | 1 + codex-rs/git-utils/src/git_process.rs | 11 +-- codex-rs/git-utils/src/operations.rs | 3 +- codex-rs/hooks/Cargo.toml | 1 + codex-rs/hooks/src/engine/command_runner.rs | 23 +++-- codex-rs/hooks/src/registry.rs | 2 +- codex-rs/login/Cargo.toml | 1 + codex-rs/login/src/auth/external_bearer.rs | 2 +- codex-rs/model-provider/Cargo.toml | 1 + .../src/amazon_bedrock/auth_refresh.rs | 11 ++- .../src/amazon_bedrock/credential_export.rs | 2 +- .../rmcp-client/src/stdio_server_launcher.rs | 9 -- codex-rs/rollout/Cargo.toml | 1 + codex-rs/rollout/src/search.rs | 2 +- codex-rs/utils/process/BUILD.bazel | 6 ++ codex-rs/utils/process/Cargo.toml | 15 ++++ .../process/src/background_command_tests.rs | 54 ++++++++++++ codex-rs/utils/process/src/lib.rs | 26 ++++++ codex-rs/utils/pty/src/child_command.rs | 11 --- codex-rs/utils/pty/src/win/job.rs | 38 +++++++- codex-rs/utils/pty/src/win/job_tests.rs | 87 +++++++++++++++++++ codex-rs/windows-sandbox-rs/Cargo.toml | 1 + .../bin/command_runner/win/cwd_junction.rs | 2 +- .../src/deny_read_resolver.rs | 3 +- codex-rs/windows-sandbox-rs/src/setup.rs | 6 +- .../src/setup_refresh_tests.rs | 64 ++++++++++++++ 44 files changed, 380 insertions(+), 71 deletions(-) create mode 100644 codex-rs/utils/process/BUILD.bazel create mode 100644 codex-rs/utils/process/Cargo.toml create mode 100644 codex-rs/utils/process/src/background_command_tests.rs create mode 100644 codex-rs/utils/process/src/lib.rs create mode 100644 codex-rs/utils/pty/src/win/job_tests.rs create mode 100644 codex-rs/windows-sandbox-rs/src/setup_refresh_tests.rs 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/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/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/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/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/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/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/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(()) +} From c248f6d48b97eb4a2aa56147a0b11b7d763278b9 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 29 Sep 2026 05:00:36 +0000 Subject: [PATCH 13/13] Fix model provider lookup for TUI history (#49171) Read `model_provider` from the effective configuration's nested `config` field in `history_model_provider`, retaining the `openai` fallback. GitOrigin-RevId: 06a8b3da1b499e673f80b8827951020c469c3970 --- codex-rs/tui/src/app_server_session/provider_selection.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/tui/src/app_server_session/provider_selection.rs b/codex-rs/tui/src/app_server_session/provider_selection.rs index 1e82c35b943a..e5a0c9082bad 100644 --- a/codex-rs/tui/src/app_server_session/provider_selection.rs +++ b/codex-rs/tui/src/app_server_session/provider_selection.rs @@ -58,6 +58,7 @@ impl AppServerSession { .await? .map(|config| { config + .config .model_provider .unwrap_or_else(|| "openai".to_string()) }))