diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 61c49b1f16..672b376483 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -658,6 +658,9 @@ "telepathy": { "type": "boolean" }, + "terminal_browser": { + "type": "boolean" + }, "terminal_resize_reflow": { "type": "boolean" }, @@ -5044,6 +5047,9 @@ "telepathy": { "type": "boolean" }, + "terminal_browser": { + "type": "boolean" + }, "terminal_resize_reflow": { "type": "boolean" }, diff --git a/codex-rs/tui/src/app/app_server_event_targets.rs b/codex-rs/tui/src/app/app_server_event_targets.rs index 6c55850e4d..d92cef3346 100644 --- a/codex-rs/tui/src/app/app_server_event_targets.rs +++ b/codex-rs/tui/src/app/app_server_event_targets.rs @@ -316,6 +316,7 @@ mod tests { ServerNotification::ThreadSettingsUpdated(ThreadSettingsUpdatedNotification { thread_id: thread_id.to_string(), thread_settings: test_thread_settings(), + network_proxy: None, }); let target = server_notification_thread_target(¬ification); diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 262fec5b2f..7a25fb242a 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -535,6 +535,16 @@ impl App { tui.set_raw_mouse_events(raw_mouse_events); tui.frame_requester().schedule_frame(); } + AppEvent::ReconcileTerminalBrowserNetworkPolicy { thread_id } => { + let origin_thread_id = conversation_origin + .and_then(|origin| self.chat_widget.by_origin(origin)) + .and_then(|pane| pane.active_thread_id.or(pane.chat_widget.thread_id())); + if self.terminal_browser_owner_thread_id == Some(thread_id) + && origin_thread_id == Some(thread_id) + { + self.reconcile_terminal_browser_network_policy().await; + } + } AppEvent::TerminalBrowserToolCompleted { request_id, response, diff --git a/codex-rs/tui/src/app/terminal_browser.rs b/codex-rs/tui/src/app/terminal_browser.rs index ae645921fa..70c4da1d9a 100644 --- a/codex-rs/tui/src/app/terminal_browser.rs +++ b/codex-rs/tui/src/app/terminal_browser.rs @@ -41,14 +41,21 @@ impl ReopenableTerminalBrowser { impl App { fn terminal_browser_network_availability(&self) -> TerminalBrowserNetworkAvailability { - TerminalBrowserNetworkAvailability::from_config(self.chat_widget.config_ref()) + TerminalBrowserNetworkAvailability::from_config_and_runtime( + self.chat_widget.config_ref(), + self.chat_widget.session_network_proxy(), + ) } fn terminal_browser_network_policy(&self) -> BrowserNetworkPolicy { match self.terminal_browser_network_availability() { TerminalBrowserNetworkAvailability::Direct => BrowserNetworkPolicy::Direct, + TerminalBrowserNetworkAvailability::ManagedProxy { http_addr } => { + BrowserNetworkPolicy::ManagedProxy { http_addr } + } TerminalBrowserNetworkAvailability::Restricted - | TerminalBrowserNetworkAvailability::ManagedProxyUnsupported => { + | TerminalBrowserNetworkAvailability::ManagedProxyUnavailable + | TerminalBrowserNetworkAvailability::ManagedProxyMitmUnsupported => { BrowserNetworkPolicy::Disabled } } @@ -423,19 +430,12 @@ impl App { } pub(super) async fn doctor_terminal_browser(&mut self) { - if let Some(message) = self - .terminal_browser_network_availability() - .unavailable_message() - { - self.chat_widget - .add_info_message(message.to_string(), /*hint*/ None); - return; - } - let Some(browser) = self.terminal_browser_for_active_thread().await else { + if !self.terminal_browser_enabled() { self.chat_widget .add_error_message("Terminal browser is not enabled.".to_string()); return; - }; + } + let browser = self.discover_terminal_browser(); let app_event_tx = self.app_event_tx.clone(); tokio::spawn(async move { let report = browser.doctor().await; diff --git a/codex-rs/tui/src/app/terminal_browser_tests.rs b/codex-rs/tui/src/app/terminal_browser_tests.rs index 7ae8238489..cd804b6210 100644 --- a/codex-rs/tui/src/app/terminal_browser_tests.rs +++ b/codex-rs/tui/src/app/terminal_browser_tests.rs @@ -17,6 +17,7 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::NetworkSandboxPolicy; use color_eyre::eyre::Result; use pretty_assertions::assert_eq; +use std::time::Duration; #[test] fn terminal_browser_requests_require_the_displayed_thread() { @@ -129,7 +130,7 @@ async fn visible_browser_updates_select_the_shared_right_rail_and_hidden_updates #[cfg(any(target_os = "macos", target_os = "linux"))] #[tokio::test] -async fn managed_network_requirements_block_browser_panel_with_explanation() { +async fn managed_network_without_runtime_blocks_panel_but_not_doctor() { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; app.chat_widget.owned_screen = App::owned_screen_for_behavior( AltScreenBehavior::Owned, @@ -185,30 +186,44 @@ async fn managed_network_requirements_block_browser_panel_with_explanation() { app.owned_screen_frame.right_rail_content(), OwnedScreenRightRailContent::Summary ); - let mut next_history_message = || { - std::iter::from_fn(|| app_event_rx.try_recv().ok()) - .find_map(|event| match event { + let panel_message = std::iter::from_fn(|| app_event_rx.try_recv().ok()) + .find_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some(cell), + AppEvent::FromConversation { event, .. } => match *event { AppEvent::InsertHistoryCell(cell) => Some(cell), - AppEvent::FromConversation { event, .. } => match *event { - AppEvent::InsertHistoryCell(cell) => Some(cell), - _ => None, - }, _ => None, - }) - .expect("managed network warning") - .display_lines(/*width*/ 80) - .into_iter() - .map(|line| line.to_string()) - .collect::>() - .join("\n") - }; - let panel_message = next_history_message(); + }, + _ => None, + }) + .expect("managed network warning") + .display_lines(/*width*/ 80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); - insta::assert_snapshot!(panel_message, @"• Terminal browser is unavailable because managed network requirements are not supported yet."); + insta::assert_snapshot!(panel_message, @"• Terminal browser is unavailable because the managed network proxy is not ready for this session."); app.doctor_terminal_browser().await; - assert_eq!(next_history_message(), panel_message); + let doctor_completed = tokio::time::timeout(Duration::from_secs(/*secs*/ 15), async { + loop { + match app_event_rx.recv().await { + Some(AppEvent::TerminalBrowserDoctorCompleted { .. }) => break true, + Some(AppEvent::FromConversation { event, .. }) + if matches!(*event, AppEvent::TerminalBrowserDoctorCompleted { .. }) => + { + break true; + } + Some(_) => {} + None => break false, + } + } + }) + .await + .expect("terminal-browser doctor should complete"); + assert!(doctor_completed); + assert!(app.terminal_browser.is_none()); } #[tokio::test] @@ -345,3 +360,96 @@ async fn summary_command_from_an_unfocused_pane_does_not_hide_the_focused_browse app_server.shutdown().await?; Ok(()) } + +#[tokio::test] +async fn network_reconciliation_follows_browser_ownership_across_pane_focus() -> Result<()> { + let mut app = make_test_app().await; + let parent_widget = + make_chatwidget_for_pane_with_sender(PaneSlot::Parent, app.app_event_tx.clone()).await; + app.chat_widget + .by_slot_mut(PaneSlot::Parent) + .expect("parent pane") + .chat_widget = parent_widget; + let parent_thread_id = ThreadId::new(); + let side_thread_id = ThreadId::new(); + let parent = app + .chat_widget + .by_slot_mut(PaneSlot::Parent) + .expect("parent pane"); + parent.attach_thread(parent_thread_id, /*receiver*/ None); + parent + .set_permission_profile_with_active_profile( + PermissionProfile::read_only(), + /*active_permission_profile*/ None, + ) + .expect("set restricted parent permissions"); + let parent_origin = parent.origin().expect("parent origin"); + let side_widget = + make_chatwidget_for_pane_with_sender(PaneSlot::Side, app.app_event_tx.clone()).await; + let file_search = FileSearchManager::new( + side_widget.config_ref().cwd.to_path_buf(), + side_widget.conversation_event_sender(), + ); + let owned_screen = App::owned_screen_for_behavior( + AltScreenBehavior::Owned, + &side_widget, + app.keymap.pager.clone(), + ); + let installed = app.chat_widget.install_side(ConversationPaneInit { + chat_widget: side_widget, + file_search, + owned_screen, + }); + assert!(installed.is_ok(), "side pane should install"); + app.chat_widget + .by_slot_mut(PaneSlot::Side) + .expect("side pane") + .attach_thread(side_thread_id, /*receiver*/ None); + let browser = Arc::new(TerminalBrowser::discover()); + browser.set_visibility(/*visible*/ true); + app.terminal_browser = Some(Arc::clone(&browser)); + app.terminal_browser_owner_thread_id = Some(side_thread_id); + assert!(app.chat_widget.focus(PaneSlot::Parent)); + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::FromConversation { + target: parent_origin, + event: Box::new(AppEvent::ReconcileTerminalBrowserNetworkPolicy { + thread_id: parent_thread_id, + }), + }, + ) + .await?; + + assert!( + browser.view().visible, + "focused non-owner must not reconcile" + ); + + app.terminal_browser_owner_thread_id = Some(parent_thread_id); + browser.set_visibility(/*visible*/ true); + assert!(app.chat_widget.focus(PaneSlot::Side)); + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::FromConversation { + target: parent_origin, + event: Box::new(AppEvent::ReconcileTerminalBrowserNetworkPolicy { + thread_id: parent_thread_id, + }), + }, + ) + .await?; + + assert!( + !browser.view().visible, + "unfocused owner must still reconcile its restricted policy" + ); + app_server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 2e537ba966..c69ffa26a5 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -6546,7 +6546,7 @@ async fn thread_setting_update_params_sync_model_and_default_reasoning() { } #[tokio::test] -async fn inactive_thread_settings_notification_updates_cached_collaboration_mode() { +async fn inactive_thread_settings_notification_updates_cached_session() { let mut app = make_test_app().await; let primary_thread_id = ThreadId::new(); let inactive_thread_id = ThreadId::new(); @@ -6602,6 +6602,11 @@ async fn inactive_thread_settings_notification_updates_cached_collaboration_mode multi_agent_mode: Default::default(), personality: Some(Personality::Pragmatic), }, + network_proxy: Some(codex_app_server_protocol::ThreadNetworkProxyRuntime { + http_addr: "127.0.0.1:43128".to_string(), + socks_addr: "127.0.0.1:43129".to_string(), + mitm: false, + }), }; app.enqueue_thread_notification( inactive_thread_id, @@ -6622,6 +6627,14 @@ async fn inactive_thread_settings_notification_updates_cached_collaboration_mode .expect("inactive session should remain cached"); assert_eq!(cached_session.model, "gpt-test"); assert_eq!(cached_session.personality, Some(Personality::Pragmatic)); + assert_eq!( + cached_session.network_proxy, + Some(crate::session_state::SessionNetworkProxyRuntime { + http_addr: "127.0.0.1:43128".to_string(), + socks_addr: "127.0.0.1:43129".to_string(), + mitm: false, + }) + ); assert_eq!( cached_session.collaboration_mode.as_deref(), Some(&collaboration_mode) @@ -6645,6 +6658,14 @@ async fn inactive_thread_settings_notification_updates_cached_collaboration_mode app.chat_widget.config_ref().personality, Some(Personality::Pragmatic) ); + assert_eq!( + app.chat_widget.session_network_proxy(), + Some(&crate::session_state::SessionNetworkProxyRuntime { + http_addr: "127.0.0.1:43128".to_string(), + socks_addr: "127.0.0.1:43129".to_string(), + mitm: false, + }) + ); } #[tokio::test] diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 66c4bbff19..6235f0691e 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -886,7 +886,7 @@ impl App { return Ok(()); } if let ServerNotification::ThreadSettingsUpdated(notification) = ¬ification { - self.apply_thread_settings_to_cached_session(thread_id, ¬ification.thread_settings) + self.apply_thread_settings_to_cached_session(thread_id, notification) .await; } let inferred_session = self diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 46e90d74fc..e7ed45dc4f 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -1,4 +1,5 @@ use super::App; +use crate::app_server_session::session_network_proxy_from_thread; use crate::session_resume::read_session_model; use crate::session_state::ThreadSessionState; use codex_app_server_protocol::AskForApproval; @@ -129,6 +130,9 @@ impl App { session.model.clear(); } session.message_history = None; + // `thread/read` is authoritative for thread-owned proxy state. In particular, a missing + // `extra.networkProxy` must clear any runtime inherited from the primary-session fallback. + session.network_proxy = session_network_proxy_from_thread(thread); session } @@ -406,9 +410,14 @@ mod tests { ThreadId::from_string("00000000-0000-0000-0000-000000000405").expect("valid thread"); let primary_session = ThreadSessionState { permission_profile: PermissionProfile::workspace_write(), + network_proxy: Some(crate::session_state::SessionNetworkProxyRuntime { + http_addr: "127.0.0.1:41000".to_string(), + socks_addr: "127.0.0.1:41001".to_string(), + mitm: false, + }), ..test_thread_session(primary_thread_id, test_path_buf("/tmp/primary")) }; - let read_thread = Thread { + let mut read_thread = Thread { id: read_thread_id.to_string(), extra: None, session_id: read_thread_id.to_string(), @@ -454,5 +463,30 @@ mod tests { "thread/read fallback must use the active widget permissions rather than stale app \ config defaults" ); + assert_eq!( + session.network_proxy, None, + "missing thread/read proxy metadata must clear the primary thread runtime" + ); + + read_thread.extra = Some(codex_app_server_protocol::ThreadExtra { + network_proxy: Some(codex_app_server_protocol::ThreadNetworkProxyRuntime { + http_addr: "127.0.0.1:42000".to_string(), + socks_addr: "127.0.0.1:42001".to_string(), + mitm: false, + }), + }); + let session = app + .session_state_for_thread_read(read_thread_id, &read_thread) + .await; + + assert_eq!( + session.network_proxy, + Some(crate::session_state::SessionNetworkProxyRuntime { + http_addr: "127.0.0.1:42000".to_string(), + socks_addr: "127.0.0.1:42001".to_string(), + mitm: false, + }), + "thread/read must replace the primary thread runtime with its own" + ); } } diff --git a/codex-rs/tui/src/app/thread_settings.rs b/codex-rs/tui/src/app/thread_settings.rs index ffc32f601f..a5cfc43154 100644 --- a/codex-rs/tui/src/app/thread_settings.rs +++ b/codex-rs/tui/src/app/thread_settings.rs @@ -5,8 +5,8 @@ use crate::app_command::AppCommand; use crate::app_server_session::AppServerSession; use crate::session_state::ThreadSessionState; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; -use codex_app_server_protocol::ThreadSettings; use codex_app_server_protocol::ThreadSettingsUpdateParams; +use codex_app_server_protocol::ThreadSettingsUpdatedNotification; use codex_protocol::ThreadId; use codex_protocol::config_types::ModeKind; use codex_protocol::models::PermissionProfile; @@ -137,18 +137,18 @@ impl App { pub(super) async fn apply_thread_settings_to_cached_session( &mut self, thread_id: ThreadId, - settings: &ThreadSettings, + notification: &ThreadSettingsUpdatedNotification, ) { if self.primary_thread_id == Some(thread_id) && let Some(session) = self.primary_session_configured.as_mut() { - apply_thread_settings_to_session(session, settings); + apply_thread_settings_to_session(session, notification); } if let Some(channel) = self.thread_event_channels.get(&thread_id) { let mut store = channel.store.lock().await; if let Some(session) = store.session.as_mut() { - apply_thread_settings_to_session(session, settings); + apply_thread_settings_to_session(session, notification); } } } @@ -169,7 +169,11 @@ impl App { } } -fn apply_thread_settings_to_session(session: &mut ThreadSessionState, settings: &ThreadSettings) { +fn apply_thread_settings_to_session( + session: &mut ThreadSessionState, + notification: &ThreadSettingsUpdatedNotification, +) { + let settings = ¬ification.thread_settings; if settings.collaboration_mode.mode == ModeKind::Default { session.model = settings.model.clone(); session.reasoning_effort = settings.effort.clone(); @@ -192,6 +196,10 @@ fn apply_thread_settings_to_session(session: &mut ThreadSessionState, settings: .clone_from(&settings.model); collaboration_mode.settings.reasoning_effort = settings.effort.clone(); session.collaboration_mode = Some(Box::new(collaboration_mode)); + session.network_proxy = notification + .network_proxy + .as_ref() + .map(crate::session_state::SessionNetworkProxyRuntime::from); } fn thread_settings_update_has_changes(params: &ThreadSettingsUpdateParams) -> bool { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 992e357cc8..68e40ced16 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -408,6 +408,11 @@ pub(crate) enum AppEvent { /// The browser's terminal frame, status, or visibility changed. TerminalBrowserUpdated, + /// Reapply a thread's network policy when it owns the shared terminal-browser process. + ReconcileTerminalBrowserNetworkPolicy { + thread_id: ThreadId, + }, + /// Complete an app-server dynamic-tool request after browser work finishes. TerminalBrowserToolCompleted { request_id: AppServerRequestId, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 0e16428950..5cf23a6f53 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -10,6 +10,7 @@ use crate::legacy_core::config::Config; use crate::permission_compat::legacy_compatible_permission_profile; use crate::service_tier_resolution; use crate::session_state::MessageHistoryMetadata; +use crate::session_state::SessionNetworkProxyRuntime; use crate::session_state::ThreadSessionState; use crate::status::StatusAccountDisplay; use crate::status::plan_type_display_name; @@ -1411,9 +1412,8 @@ fn thread_start_params_from_config( let dynamic_tools = (matches!(thread_params_mode, ThreadParamsMode::Embedded) && cfg!(any(target_os = "macos", target_os = "linux")) && config.features.enabled(Feature::TerminalBrowser) - && TerminalBrowserNetworkAvailability::from_config(config) - == TerminalBrowserNetworkAvailability::Direct) - .then(dynamic_tool_specs); + && TerminalBrowserNetworkAvailability::dynamic_tools_supported(config)) + .then(dynamic_tool_specs); let permissions = permissions_selection_from_config(config, thread_params_mode); let sandbox = permissions .is_none() @@ -1605,6 +1605,7 @@ async fn thread_session_state_from_thread_start_response( response.runtime_workspace_roots.clone(), response.instruction_source_path_uris(), response.reasoning_effort.clone(), + session_network_proxy_from_thread(&response.thread), config, ) .await @@ -1646,6 +1647,7 @@ async fn thread_session_state_from_thread_resume_response( response.runtime_workspace_roots.clone(), response.instruction_source_path_uris(), response.reasoning_effort.clone(), + session_network_proxy_from_thread(&response.thread), config, ) .await @@ -1678,6 +1680,7 @@ async fn thread_session_state_from_thread_fork_response( response.runtime_workspace_roots.clone(), response.instruction_source_path_uris(), response.reasoning_effort.clone(), + session_network_proxy_from_thread(&response.thread), config, ) .await @@ -1697,6 +1700,17 @@ fn display_permission_profile_from_thread_response( } } +pub(crate) fn session_network_proxy_from_thread( + thread: &Thread, +) -> Option { + thread + .extra + .as_ref()? + .network_proxy + .as_ref() + .map(SessionNetworkProxyRuntime::from) +} + #[expect( clippy::too_many_arguments, reason = "session mapping keeps explicit fields" @@ -1717,6 +1731,7 @@ async fn thread_session_state_from_thread_response( runtime_workspace_roots: Vec, instruction_source_paths: Vec, reasoning_effort: Option, + network_proxy: Option, config: &Config, ) -> Result { let thread_id = ThreadId::from_string(thread_id) @@ -1751,7 +1766,7 @@ async fn thread_session_state_from_thread_response( log_id, entry_count, }), - network_proxy: None, + network_proxy, rollout_path, }) } @@ -1953,7 +1968,7 @@ mod tests { #[cfg(any(target_os = "macos", target_os = "linux"))] #[tokio::test] - async fn terminal_browser_dynamic_tools_require_supported_embedded_network_mode() { + async fn terminal_browser_dynamic_tools_allow_non_mitm_managed_network() { let direct_config = build_terminal_browser_config(/*managed_network*/ false).await; let managed_config = build_terminal_browser_config(/*managed_network*/ true).await; assert_eq!( @@ -2007,7 +2022,7 @@ mod tests { "managed network", &managed_config, ThreadParamsMode::Embedded, - false, + true, ), ]; for (name, config, mode, expected) in cases { @@ -2455,7 +2470,13 @@ mod tests { let response = ThreadResumeResponse { thread: codex_app_server_protocol::Thread { id: thread_id.to_string(), - extra: None, + extra: Some(codex_app_server_protocol::ThreadExtra { + network_proxy: Some(codex_app_server_protocol::ThreadNetworkProxyRuntime { + http_addr: "127.0.0.1:43128".to_string(), + socks_addr: "127.0.0.1:43129".to_string(), + mitm: false, + }), + }), session_id: ThreadId::new().to_string(), forked_from_id: Some(forked_from_id.to_string()), parent_thread_id: None, @@ -2542,6 +2563,14 @@ mod tests { response.instruction_source_path_uris() ); assert_eq!(started.session.permission_profile, read_only_profile); + assert_eq!( + started.session.network_proxy, + Some(SessionNetworkProxyRuntime { + http_addr: "127.0.0.1:43128".to_string(), + socks_addr: "127.0.0.1:43129".to_string(), + mitm: false, + }) + ); assert_eq!(started.turns.len(), 1); assert_eq!(started.turns[0], response.thread.turns[0]); @@ -2636,6 +2665,11 @@ mod tests { codex_message_history::append_entry("newer", &thread_id, &history_config) .await .expect("history append should succeed"); + let network_proxy = SessionNetworkProxyRuntime { + http_addr: "127.0.0.1:43128".to_string(), + socks_addr: "127.0.0.1:43129".to_string(), + mitm: false, + }; let session = thread_session_state_from_thread_response( &thread_id.to_string(), @@ -2653,6 +2687,7 @@ mod tests { Vec::new(), Vec::new(), /*reasoning_effort*/ None, + Some(network_proxy.clone()), &config, ) .await @@ -2663,6 +2698,7 @@ mod tests { .expect("session should include message-history metadata"); assert_ne!(metadata.log_id, 0); assert_eq!(metadata.entry_count, 2); + assert_eq!(session.network_proxy, Some(network_proxy)); } #[tokio::test] @@ -2688,6 +2724,7 @@ mod tests { Vec::new(), Vec::new(), /*reasoning_effort*/ None, + /*network_proxy*/ None, &config, ) .await diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index acebe2b773..0712b9a0af 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -55,6 +55,10 @@ impl ChatWidget { self.config.permissions.network = network; } + pub(crate) fn session_network_proxy(&self) -> Option<&SessionNetworkProxyRuntime> { + self.session_network_proxy.as_ref() + } + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) fn set_windows_sandbox_mode(&mut self, mode: Option) { self.config.permissions.windows_sandbox_mode = mode; @@ -376,7 +380,13 @@ impl ChatWidget { return; } + self.session_network_proxy = notification + .network_proxy + .as_ref() + .map(SessionNetworkProxyRuntime::from); self.apply_thread_settings(notification.thread_settings); + self.app_event_tx + .send(AppEvent::ReconcileTerminalBrowserNetworkPolicy { thread_id }); } #[cfg(test)] diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 9ea55082e7..f65717adf0 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -718,6 +718,9 @@ impl ChatWidget { SlashCommand::Browser => { let parts = trimmed.split_whitespace().collect::>(); match parts.as_slice() { + [command] if command.eq_ignore_ascii_case("show") => { + self.app_event_tx.send(AppEvent::ShowTerminalBrowser); + } [command] if command.eq_ignore_ascii_case("close") => { self.app_event_tx.send(AppEvent::CloseTerminalBrowser); } @@ -787,7 +790,7 @@ impl ChatWidget { ); } _ => self.add_error_message( - "Usage: /browser [close|doctor|control|profile list|profile ephemeral|profile create |profile use |profile forget --confirm]" + "Usage: /browser [show|close|doctor|control|profile list|profile ephemeral|profile create |profile use |profile forget --confirm]" .to_string(), ), } diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 952aff9f95..a4d8ae08d9 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -33,6 +33,7 @@ fn thread_settings_for_test( multi_agent_mode: Default::default(), personality: Some(Personality::Pragmatic), }, + network_proxy: None, } } @@ -365,6 +366,57 @@ async fn thread_settings_updated_updates_visible_state_without_transcript() { assert_eq!(chat.current_model(), "gpt-5.4"); } +#[tokio::test] +async fn thread_settings_updated_replaces_proxy_runtime_and_requests_reconciliation() { + let (mut chat, mut rx, _op_rx) = + make_chatwidget_manual(/*model_override*/ Some("gpt-5.3-codex")).await; + let thread_id = ThreadId::new(); + chat.handle_thread_session(configured_thread_session(thread_id)); + let _ = drain_insert_history(&mut rx); + let expected_runtime = crate::session_state::SessionNetworkProxyRuntime { + http_addr: "127.0.0.1:43128".to_string(), + socks_addr: "127.0.0.1:43129".to_string(), + mitm: false, + }; + let mut notification = thread_settings_for_test("gpt-5.4", thread_id); + notification.network_proxy = Some(codex_app_server_protocol::ThreadNetworkProxyRuntime { + http_addr: expected_runtime.http_addr.clone(), + socks_addr: expected_runtime.socks_addr.clone(), + mitm: expected_runtime.mitm, + }); + + chat.handle_server_notification( + ServerNotification::ThreadSettingsUpdated(notification), + /*replay_kind*/ None, + ); + + assert_eq!(chat.session_network_proxy(), Some(&expected_runtime)); + assert!( + std::iter::from_fn(|| rx.try_recv().ok()).any(|event| matches!( + event, + AppEvent::ReconcileTerminalBrowserNetworkPolicy { + thread_id: event_thread_id, + } if event_thread_id == thread_id + )) + ); + + let notification = thread_settings_for_test("gpt-5.4", thread_id); + chat.handle_server_notification( + ServerNotification::ThreadSettingsUpdated(notification), + /*replay_kind*/ None, + ); + + assert_eq!(chat.session_network_proxy(), None); + assert!( + std::iter::from_fn(|| rx.try_recv().ok()).any(|event| matches!( + event, + AppEvent::ReconcileTerminalBrowserNetworkPolicy { + thread_id: event_thread_id, + } if event_thread_id == thread_id + )) + ); +} + #[tokio::test] async fn thread_settings_updated_preserves_default_settings_for_plan_mode() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index d89188b84c..d98b145947 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -2968,6 +2968,22 @@ async fn bare_browser_slash_command_emits_show_event() { ); } +#[tokio::test] +async fn browser_show_alias_emits_show_event() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::TerminalBrowser, /*enabled*/ true); + + chat.dispatch_command_with_args(SlashCommand::Browser, "show".to_string(), Vec::new()); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!( + events + .iter() + .any(|event| matches!(event, AppEvent::ShowTerminalBrowser)), + "expected /browser show to show the browser panel; events: {events:?}" + ); +} + #[tokio::test] async fn bare_summary_slash_command_selects_summary_panel() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index c02d4c8261..4ef3218ea2 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -37,6 +37,7 @@ pub(crate) fn new_debug_config_output( let SessionNetworkProxyRuntime { http_addr, socks_addr, + mitm: _, } = proxy; let all_proxy = session_all_proxy_url( http_addr, diff --git a/codex-rs/tui/src/session_state.rs b/codex-rs/tui/src/session_state.rs index 059e148a79..ea02483099 100644 --- a/codex-rs/tui/src/session_state.rs +++ b/codex-rs/tui/src/session_state.rs @@ -18,6 +18,17 @@ use codex_utils_path_uri::PathUri; pub(crate) struct SessionNetworkProxyRuntime { pub(crate) http_addr: String, pub(crate) socks_addr: String, + pub(crate) mitm: bool, +} + +impl From<&codex_app_server_protocol::ThreadNetworkProxyRuntime> for SessionNetworkProxyRuntime { + fn from(value: &codex_app_server_protocol::ThreadNetworkProxyRuntime) -> Self { + Self { + http_addr: value.http_addr.clone(), + socks_addr: value.socks_addr.clone(), + mitm: value.mitm, + } + } } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] diff --git a/codex-rs/tui/src/terminal_browser/network.rs b/codex-rs/tui/src/terminal_browser/network.rs index 6c7c98e8b8..7e3757831d 100644 --- a/codex-rs/tui/src/terminal_browser/network.rs +++ b/codex-rs/tui/src/terminal_browser/network.rs @@ -1,33 +1,72 @@ //! Network availability shared by terminal-browser tools and panel commands. +use std::net::SocketAddr; + use crate::legacy_core::config::Config; +use crate::session_state::SessionNetworkProxyRuntime; /// Effective network modes relevant to the Carbonyl runtime. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum TerminalBrowserNetworkAvailability { Direct, + ManagedProxy { http_addr: SocketAddr }, Restricted, - ManagedProxyUnsupported, + ManagedProxyUnavailable, + ManagedProxyMitmUnsupported, } impl TerminalBrowserNetworkAvailability { - pub(crate) fn from_config(config: &Config) -> Self { + pub(crate) fn from_config_and_runtime( + config: &Config, + network_proxy: Option<&SessionNetworkProxyRuntime>, + ) -> Self { if !config.permissions.network_sandbox_policy().is_enabled() { Self::Restricted - } else if config.permissions.network.is_some() { - Self::ManagedProxyUnsupported + } else if let Some(network) = config.permissions.network.as_ref() { + if network.mitm_enabled() { + return Self::ManagedProxyMitmUnsupported; + } + let Some(network_proxy) = network_proxy else { + return Self::ManagedProxyUnavailable; + }; + if network_proxy.mitm { + return Self::ManagedProxyMitmUnsupported; + } + let Ok(http_addr) = network_proxy.http_addr.parse::() else { + return Self::ManagedProxyUnavailable; + }; + if !http_addr.ip().is_loopback() || http_addr.port() == 0 { + return Self::ManagedProxyUnavailable; + } + Self::ManagedProxy { http_addr } } else { Self::Direct } } + pub(crate) fn dynamic_tools_supported(config: &Config) -> bool { + config.permissions.network_sandbox_policy().is_enabled() + && config + .permissions + .network + .as_ref() + .is_none_or(|network| !network.mitm_enabled()) + } + pub(crate) fn unavailable_message(self) -> Option<&'static str> { match self { - Self::Direct => None, + Self::Direct | Self::ManagedProxy { .. } => None, Self::Restricted => Some("Terminal browser requires network access for this session."), - Self::ManagedProxyUnsupported => Some( - "Terminal browser is unavailable because managed network requirements are not supported yet.", + Self::ManagedProxyUnavailable => Some( + "Terminal browser is unavailable because the managed network proxy is not ready for this session.", + ), + Self::ManagedProxyMitmUnsupported => Some( + "Terminal browser is unavailable because managed TLS interception is not supported.", ), } } } + +#[cfg(test)] +#[path = "network_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/terminal_browser/network_tests.rs b/codex-rs/tui/src/terminal_browser/network_tests.rs new file mode 100644 index 0000000000..bd35001d69 --- /dev/null +++ b/codex-rs/tui/src/terminal_browser/network_tests.rs @@ -0,0 +1,104 @@ +use crate::legacy_core::config::Config; +use crate::legacy_core::config::ConfigBuilder; +use crate::legacy_core::config::ConfigOverrides; +use crate::session_state::SessionNetworkProxyRuntime; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::NetworkSandboxPolicy; +use pretty_assertions::assert_eq; + +use super::TerminalBrowserNetworkAvailability; + +async fn terminal_browser_config(managed_network: bool) -> Config { + let codex_home = tempfile::tempdir().expect("terminal-browser config home"); + let mut cli_overrides = Vec::new(); + if managed_network { + cli_overrides.push(( + "features.network_proxy".to_string(), + toml::Value::Boolean(true), + )); + } + ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .loader_overrides(codex_config::LoaderOverrides::without_managed_config_for_tests()) + .cli_overrides(cli_overrides) + .harness_overrides(ConfigOverrides { + permission_profile: Some(PermissionProfile::workspace_write_with( + &[], + NetworkSandboxPolicy::Enabled, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + )), + ..ConfigOverrides::default() + }) + .build() + .await + .expect("terminal-browser config should build") +} + +fn runtime(http_addr: &str, mitm: bool) -> SessionNetworkProxyRuntime { + SessionNetworkProxyRuntime { + http_addr: http_addr.to_string(), + socks_addr: "127.0.0.1:43129".to_string(), + mitm, + } +} + +#[tokio::test] +async fn direct_network_does_not_require_runtime_proxy_details() { + let config = terminal_browser_config(/*managed_network*/ false).await; + + assert_eq!( + TerminalBrowserNetworkAvailability::from_config_and_runtime( + &config, /*network_proxy*/ None, + ), + TerminalBrowserNetworkAvailability::Direct + ); + assert!(TerminalBrowserNetworkAvailability::dynamic_tools_supported( + &config + )); +} + +#[tokio::test] +async fn managed_network_requires_a_non_mitm_loopback_runtime_proxy() { + let config = terminal_browser_config(/*managed_network*/ true).await; + assert!(config.permissions.network.is_some()); + assert!(TerminalBrowserNetworkAvailability::dynamic_tools_supported( + &config + )); + + assert_eq!( + TerminalBrowserNetworkAvailability::from_config_and_runtime( + &config, /*network_proxy*/ None, + ), + TerminalBrowserNetworkAvailability::ManagedProxyUnavailable + ); + for unavailable in [ + runtime("not-an-address", /*mitm*/ false), + runtime("192.0.2.10:43128", /*mitm*/ false), + runtime("127.0.0.1:0", /*mitm*/ false), + ] { + assert_eq!( + TerminalBrowserNetworkAvailability::from_config_and_runtime( + &config, + Some(&unavailable), + ), + TerminalBrowserNetworkAvailability::ManagedProxyUnavailable + ); + } + assert_eq!( + TerminalBrowserNetworkAvailability::from_config_and_runtime( + &config, + Some(&runtime("127.0.0.1:43128", /*mitm*/ true)), + ), + TerminalBrowserNetworkAvailability::ManagedProxyMitmUnsupported + ); + assert_eq!( + TerminalBrowserNetworkAvailability::from_config_and_runtime( + &config, + Some(&runtime("127.0.0.1:43128", /*mitm*/ false)), + ), + TerminalBrowserNetworkAvailability::ManagedProxy { + http_addr: "127.0.0.1:43128".parse().expect("proxy address"), + } + ); +}