mirror of
https://github.com/openai/codex.git
synced 2026-10-22 17:58:46 +00:00
feat(tui): route browser through managed proxy
This commit is contained in:
@@ -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(¬ification);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user