diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index eb9933b89c..c4faba0050 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -10,6 +10,8 @@ use crate::app_event::ConnectorsSnapshot; use crate::app_info::app_info_from_api; use crate::app_server_session::AppServerSession; use crate::app_server_session::status_account_display_from_auth_mode; +use crate::terminal_browser::TERMINAL_BROWSER_NAMESPACE; +use crate::terminal_browser::dynamic_tool_response; use codex_app_server_client::AppServerEvent; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::ServerNotification; @@ -232,6 +234,53 @@ impl App { return; } + if let ServerRequest::DynamicToolCall { request_id, params } = &request + && params.namespace.as_deref() == Some(TERMINAL_BROWSER_NAMESPACE) + { + if !self.terminal_browser_request_matches_active_thread(¶ms.thread_id) { + self.app_event_tx + .send(AppEvent::TerminalBrowserToolCompleted { + request_id: request_id.clone(), + response: dynamic_tool_response(Err(anyhow::anyhow!( + "terminal browser permission policy only allows the active TUI thread" + ))), + profile_approval: None, + }); + return; + } + let Some(browser) = self.terminal_browser_for_active_request().await else { + self.app_event_tx + .send(AppEvent::TerminalBrowserToolCompleted { + request_id: request_id.clone(), + response: dynamic_tool_response(Err(anyhow::anyhow!( + "terminal browser permission policy disables this TUI session" + ))), + profile_approval: None, + }); + return; + }; + let request_id = request_id.clone(); + let session_key = params.thread_id.clone(); + let tool = params.tool.clone(); + let arguments = params.arguments.clone(); + let profile_approval = (tool == "profile") + .then(|| crate::terminal_browser::requested_profile_command(&arguments)) + .flatten() + .and_then(|command| self.terminal_browser_profile_approval(command)); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = browser.execute(&session_key, &tool, arguments).await; + let profile_approval = result.is_ok().then_some(profile_approval).flatten(); + let response = dynamic_tool_response(result); + app_event_tx.send(AppEvent::TerminalBrowserToolCompleted { + request_id, + response, + profile_approval, + }); + }); + return; + } + if let Some(unsupported) = self .pending_app_server_requests .note_server_request(&request) diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 1cc2452965..8a83d2bee4 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -13,6 +13,7 @@ use crate::session_state::MessageHistoryMetadata; use crate::session_state::ThreadSessionState; use crate::status::StatusAccountDisplay; use crate::status::plan_type_display_name; +use crate::terminal_browser::dynamic_tool_specs; use crate::terminal_visualization_instructions::with_terminal_visualization_instructions; use codex_app_server_client::AppServerClient; use codex_app_server_client::AppServerEvent; @@ -110,6 +111,7 @@ use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; +use codex_features::Feature; use codex_otel::TelemetryAuthMode; use codex_protocol::ThreadId; use codex_protocol::approvals::GuardianAssessmentEvent; @@ -1405,6 +1407,12 @@ fn thread_start_params_from_config( remote_cwd_override: Option<&std::path::Path>, session_start_source: Option, ) -> ThreadStartParams { + let dynamic_tools = (matches!(thread_params_mode, ThreadParamsMode::Embedded) + && cfg!(any(target_os = "macos", target_os = "linux")) + && config.features.enabled(Feature::TerminalBrowser) + && config.permissions.network_sandbox_policy().is_enabled() + && config.permissions.network.is_none()) + .then(dynamic_tool_specs); let permissions = permissions_selection_from_config(config, thread_params_mode); let sandbox = permissions .is_none() @@ -1432,6 +1440,7 @@ fn thread_start_params_from_config( developer_instructions: with_terminal_visualization_instructions( config, /*control_instructions*/ None, ), + dynamic_tools, ..ThreadStartParams::default() } } @@ -1803,6 +1812,37 @@ mod tests { .expect("config should build") } + #[cfg(any(target_os = "macos", target_os = "linux"))] + async fn build_terminal_browser_config(managed_network: bool) -> Config { + let codex_home = tempfile::tempdir().expect("terminal-browser config home"); + let mut cli_overrides = vec![( + "features.terminal_browser".to_string(), + toml::Value::Boolean(true), + )]; + 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 rate_limit_snapshot(limit_id: &str) -> RateLimitSnapshot { RateLimitSnapshot { limit_id: Some(limit_id.to_string()), @@ -1910,6 +1950,73 @@ mod tests { assert_eq!(params.thread_source, Some(ThreadSource::User)); } + #[cfg(any(target_os = "macos", target_os = "linux"))] + #[tokio::test] + async fn terminal_browser_dynamic_tools_require_supported_embedded_network_mode() { + let direct_config = build_terminal_browser_config(/*managed_network*/ false).await; + let managed_config = build_terminal_browser_config(/*managed_network*/ true).await; + assert_eq!( + direct_config.permissions.network_sandbox_policy(), + NetworkSandboxPolicy::Enabled + ); + assert!(direct_config.permissions.network.is_none()); + assert!(managed_config.permissions.network.is_some()); + + let mut feature_disabled_config = direct_config.clone(); + feature_disabled_config + .features + .disable(Feature::TerminalBrowser) + .expect("terminal-browser feature should be mutable in tests"); + let mut restricted_config = direct_config.clone(); + restricted_config + .permissions + .set_permission_profile(PermissionProfile::read_only()) + .expect("read-only permission profile should be allowed in tests"); + assert_eq!( + restricted_config.permissions.network_sandbox_policy(), + NetworkSandboxPolicy::Restricted + ); + + let cases = [ + ( + "embedded direct", + &direct_config, + ThreadParamsMode::Embedded, + true, + ), + ( + "remote direct", + &direct_config, + ThreadParamsMode::Remote, + false, + ), + ( + "feature disabled", + &feature_disabled_config, + ThreadParamsMode::Embedded, + false, + ), + ( + "network restricted", + &restricted_config, + ThreadParamsMode::Embedded, + false, + ), + ( + "managed network", + &managed_config, + ThreadParamsMode::Embedded, + false, + ), + ]; + for (name, config, mode, expected) in cases { + let params = thread_start_params_from_config( + config, mode, /*remote_cwd_override*/ None, /*session_start_source*/ None, + ); + assert_eq!(params.dynamic_tools.is_some(), expected, "{name}"); + } + } + #[tokio::test] async fn thread_start_params_can_mark_clear_source() { let temp_dir = tempfile::tempdir().expect("tempdir");