feat(tui): route browser through managed proxy

This commit is contained in:
Felipe Coury
2026-07-06 16:43:22 -03:00
parent 532be8ddb8
commit 8e0ae8c575
19 changed files with 520 additions and 54 deletions
+6
View File
@@ -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"
},
@@ -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(&notification);
+10
View File
@@ -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,
+12 -12
View File
@@ -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;
+127 -19
View File
@@ -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::<Vec<_>>()
.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::<Vec<_>>()
.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(())
}
+22 -1
View File
@@ -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]
+1 -1
View File
@@ -886,7 +886,7 @@ impl App {
return Ok(());
}
if let ServerNotification::ThreadSettingsUpdated(notification) = &notification {
self.apply_thread_settings_to_cached_session(thread_id, &notification.thread_settings)
self.apply_thread_settings_to_cached_session(thread_id, notification)
.await;
}
let inferred_session = self
+35 -1
View File
@@ -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"
);
}
}
+13 -5
View File
@@ -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 = &notification.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 {
+5
View File
@@ -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,
+44 -7
View File
@@ -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<SessionNetworkProxyRuntime> {
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<AbsolutePathBuf>,
instruction_source_paths: Vec<PathUri>,
reasoning_effort: Option<codex_protocol::openai_models::ReasoningEffort>,
network_proxy: Option<SessionNetworkProxyRuntime>,
config: &Config,
) -> Result<ThreadSessionState, String> {
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
+10
View File
@@ -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<WindowsSandboxModeToml>) {
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)]
@@ -718,6 +718,9 @@ impl ChatWidget {
SlashCommand::Browser => {
let parts = trimmed.split_whitespace().collect::<Vec<_>>();
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 <name>|profile use <name>|profile forget <name> --confirm]"
"Usage: /browser [show|close|doctor|control|profile list|profile ephemeral|profile create <name>|profile use <name>|profile forget <name> --confirm]"
.to_string(),
),
}
@@ -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;
@@ -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::<Vec<_>>();
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;
+1
View File
@@ -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,
+11
View File
@@ -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)]
+46 -7
View File
@@ -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::<SocketAddr>() 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;
@@ -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"),
}
);
}