Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,10 @@ pub struct SettingsSnapshot {
agent_sessions_enabled: bool,
agent_session_ssh_hosts: Vec<String>,
hooks_enabled: bool,
http_proxy_enabled: bool,
http_proxy_url: String,
http_proxy_username: String,
http_proxy_password: String,
ui_language: &'static str,
theme: &'static str,
window_scale_percent: u16,
Expand Down Expand Up @@ -535,6 +539,10 @@ impl From<Settings> for SettingsSnapshot {
agent_sessions_enabled: settings.agent_sessions_enabled,
agent_session_ssh_hosts: settings.agent_session_ssh_hosts,
hooks_enabled: settings.hooks_enabled,
http_proxy_enabled: settings.http_proxy_enabled,
http_proxy_url: settings.http_proxy_url,
http_proxy_username: settings.http_proxy_username,
http_proxy_password: settings.http_proxy_password,
ui_language: language_label(settings.ui_language),
theme: theme_label(settings.theme),
window_scale_percent: settings.window_scale_percent,
Expand Down
16 changes: 16 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ pub struct SettingsUpdate {
pub agent_sessions_enabled: Option<bool>,
pub agent_session_ssh_hosts: Option<Vec<String>>,
pub hooks_enabled: Option<bool>,
pub http_proxy_enabled: Option<bool>,
pub http_proxy_url: Option<String>,
pub http_proxy_username: Option<String>,
pub http_proxy_password: Option<String>,
pub ui_language: Option<String>,
pub theme: Option<String>,
pub window_scale_percent: Option<u16>,
Expand Down Expand Up @@ -258,6 +262,18 @@ impl SettingsUpdate {
if let Some(v) = self.hooks_enabled {
settings.hooks_enabled = v;
}
if let Some(v) = self.http_proxy_enabled {
settings.http_proxy_enabled = v;
}
if let Some(v) = self.http_proxy_url.clone() {
settings.http_proxy_url = v.trim().to_string();
}
if let Some(v) = self.http_proxy_username.clone() {
settings.http_proxy_username = v.trim().to_string();
}
if let Some(v) = self.http_proxy_password.clone() {
settings.http_proxy_password = v;
}
if let Some(v) = self.install_updates_on_quit {
settings.install_updates_on_quit = v;
}
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop-tauri/src/i18n/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,16 @@ export const ALL_LOCALE_KEYS = [
"HooksEnableLabel",
"HooksEnableHelper",
"HooksConfigPathHint",
"NetworkProxyInvalidUrl",
"NetworkProxyPasswordHelper",
"NetworkProxyPasswordLabel",
"NetworkProxyUserLabel",
"NetworkProxyUrlHelper",
"NetworkProxyUrlLabel",
"NetworkProxyEnableHelper",
"NetworkProxyEnableLabel",
"NetworkProxyCaption",
"NetworkProxyTitle",
"UsageSpendTitle",
"UsageSpendCaption",
"UsageSpendRefresh",
Expand Down
62 changes: 62 additions & 0 deletions apps/desktop-tauri/src/surfaces/settings/tabs/AdvancedTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,68 @@ export default function AdvancedTab({ settings, set, saving }: TabProps) {
</div>
</section>

{/* -- Network proxy ---------------------------------------- */}
<section className="settings-section">
<h3 className="settings-section__title">{t("NetworkProxyTitle")}</h3>
<p className="settings-section__caption">{t("NetworkProxyCaption")}</p>
<div className="settings-section__group">
<Field
label={t("NetworkProxyEnableLabel")}
description={t("NetworkProxyEnableHelper")}
leading
>
<Toggle
checked={settings.httpProxyEnabled ?? false}
disabled={saving}
onChange={(v) => set({ httpProxyEnabled: v })}
/>
</Field>
<Field
label={t("NetworkProxyUrlLabel")}
description={t("NetworkProxyUrlHelper")}
>
<input
type="text"
className="text-input"
value={settings.httpProxyUrl ?? ""}
placeholder="http://127.0.0.1:7890"
disabled={saving || !settings.httpProxyEnabled}
onChange={(event) => set({ httpProxyUrl: event.target.value })}
onBlur={(event) =>
set({ httpProxyUrl: event.target.value.trim() })
}
/>
</Field>
<Field label={t("NetworkProxyUserLabel")}>
<input
type="text"
className="text-input"
value={settings.httpProxyUsername ?? ""}
autoComplete="off"
disabled={saving || !settings.httpProxyEnabled}
onChange={(event) =>
set({ httpProxyUsername: event.target.value })
}
/>
</Field>
<Field
label={t("NetworkProxyPasswordLabel")}
description={t("NetworkProxyPasswordHelper")}
>
<input
type="password"
className="text-input"
value={settings.httpProxyPassword ?? ""}
autoComplete="new-password"
disabled={saving || !settings.httpProxyEnabled}
onChange={(event) =>
set({ httpProxyPassword: event.target.value })
}
/>
</Field>
</div>
</section>

{/* -- External hooks --------------------------------------- */}
<section className="settings-section">
<h3 className="settings-section__title">{t("HooksTitle")}</h3>
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop-tauri/src/types/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,11 @@ export interface SettingsSnapshot {
agentSessionSshHosts?: string[];
/** Master switch for external hooks (hooks.json next to settings). */
hooksEnabled?: boolean;
/** Route provider HTTPS through a user HTTP(S) proxy (#235). */
httpProxyEnabled?: boolean;
httpProxyUrl?: string;
httpProxyUsername?: string;
httpProxyPassword?: string;
uiLanguage: Language;
theme: ThemePreference;
/** 100..=250 — clamped server-side. */
Expand Down Expand Up @@ -322,6 +327,10 @@ export interface SettingsUpdate {
agentSessionsEnabled?: boolean;
agentSessionSshHosts?: string[];
hooksEnabled?: boolean;
httpProxyEnabled?: boolean;
httpProxyUrl?: string;
httpProxyUsername?: string;
httpProxyPassword?: string;
uiLanguage?: Language;
theme?: ThemePreference;
windowScalePercent?: number;
Expand Down
10 changes: 8 additions & 2 deletions rust/src/core/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,19 @@

use reqwest::Url;

use super::http_proxy::apply_app_proxy;

/// Build a client for requests that may carry cookies, OAuth tokens, API keys,
/// or other provider credentials.
///
/// Credentialed provider requests should not automatically follow redirects to
/// a different origin. Reqwest strips some sensitive headers during redirects,
/// but an explicit same-origin policy keeps the invariant local and testable.
///
/// When Settings → Advanced HTTP proxy is enabled, the global proxy is applied
/// here so provider refreshes pick it up on the next `instantiate_provider`.
pub fn credentialed_http_client_builder() -> reqwest::ClientBuilder {
reqwest::Client::builder().redirect(reqwest::redirect::Policy::custom(|attempt| {
let builder = reqwest::Client::builder().redirect(reqwest::redirect::Policy::custom(|attempt| {
let previous = attempt.previous();
let Some(last_url) = previous.last() else {
return attempt.follow();
Expand All @@ -20,7 +25,8 @@ pub fn credentialed_http_client_builder() -> reqwest::ClientBuilder {
} else {
attempt.stop()
}
}))
}));
apply_app_proxy(builder)
}

pub(crate) fn is_same_origin(from: &Url, to: &Url) -> bool {
Expand Down
177 changes: 177 additions & 0 deletions rust/src/core/http_proxy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
//! Global HTTP(S) proxy configuration for provider / app network traffic.
//!
//! Issue #235 — opt-in settings proxy (not CLIProxyAPI, not LLM Proxy provider).
//! Password may live in local settings.json (same trust boundary as other local secrets).

use reqwest::{ClientBuilder, Proxy, Url};

/// Snapshot of proxy-related settings used when building HTTP clients.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HttpProxySettings {
pub enabled: bool,
pub url: String,
pub username: String,
pub password: String,
}

impl HttpProxySettings {
pub fn from_parts(
enabled: bool,
url: impl Into<String>,
username: impl Into<String>,
password: impl Into<String>,
) -> Self {
Self {
enabled,
url: url.into(),
username: username.into(),
password: password.into(),
}
}

/// Load from persisted app settings (no-op if settings fail to load).
pub fn from_app_settings() -> Self {
let settings = crate::settings::Settings::load();
Self {
enabled: settings.http_proxy_enabled,
url: settings.http_proxy_url.clone(),
username: settings.http_proxy_username.clone(),
password: settings.http_proxy_password.clone(),
}
}
}

/// Resolve a reqwest [`Proxy`] from settings.
///
/// - Disabled or empty URL → `Ok(None)` (direct / default).
/// - Invalid URL when enabled → `Err(...)`.
/// - Supports `http` and `https` proxy schemes only (MVP).
pub fn resolve_proxy(settings: &HttpProxySettings) -> Result<Option<Proxy>, String> {
if !settings.enabled {
return Ok(None);
}
let raw = settings.url.trim();
if raw.is_empty() {
return Err("Proxy URL is required when the proxy is enabled.".into());
}

let parsed = Url::parse(raw).map_err(|e| format!("Invalid proxy URL: {e}"))?;
let scheme = parsed.scheme().to_ascii_lowercase();
if scheme != "http" && scheme != "https" {
return Err(format!(
"Unsupported proxy scheme '{scheme}'. Use http:// or https://."
));
}
if parsed.host_str().is_none() {
return Err("Proxy URL must include a host.".into());
}

let mut proxy = Proxy::all(raw).map_err(|e| format!("Invalid proxy URL: {e}"))?;

// Prefer explicit username/password fields. If the URL already embeds
// credentials, do not call basic_auth again (would double-apply).
let url_has_user = !parsed.username().is_empty();
let user = settings.username.trim();
let pass = settings.password.as_str();
if !user.is_empty() && !url_has_user {
proxy = proxy.basic_auth(user, pass);
}

Ok(Some(proxy))
}

/// Apply resolved proxy to a client builder.
///
/// On misconfiguration, returns the builder unchanged so callers can still
/// connect direct (caller should log the error).
pub fn apply_proxy_to_builder(
builder: ClientBuilder,
settings: &HttpProxySettings,
) -> ClientBuilder {
match resolve_proxy(settings) {
Ok(Some(proxy)) => builder.proxy(proxy),
Ok(None) => builder,
Err(err) => {
tracing::warn!(error = %err, "http proxy config ignored; using direct connection");
builder
}
}
}

/// Convenience: apply the current app settings proxy to a builder.
pub fn apply_app_proxy(builder: ClientBuilder) -> ClientBuilder {
apply_proxy_to_builder(builder, &HttpProxySettings::from_app_settings())
}

/// Validate proxy settings for UI (returns `None` when OK / disabled).
pub fn validation_error(settings: &HttpProxySettings) -> Option<String> {
if !settings.enabled {
return None;
}
resolve_proxy(settings).err()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn disabled_returns_none() {
let s = HttpProxySettings::from_parts(false, "http://127.0.0.1:7890", "", "");
assert!(resolve_proxy(&s).unwrap().is_none());
}

#[test]
fn enabled_empty_url_errors() {
let s = HttpProxySettings::from_parts(true, " ", "", "");
assert!(resolve_proxy(&s).unwrap_err().contains("required"));
}

#[test]
fn valid_http_url_ok() {
let s = HttpProxySettings::from_parts(true, "http://127.0.0.1:7890", "", "");
assert!(resolve_proxy(&s).unwrap().is_some());
}

#[test]
fn rejects_socks_scheme() {
let s = HttpProxySettings::from_parts(true, "socks5://127.0.0.1:1080", "", "");
let err = resolve_proxy(&s).unwrap_err();
assert!(err.contains("Unsupported"), "{err}");
}

#[test]
fn rejects_garbage_url() {
let s = HttpProxySettings::from_parts(true, "not a url", "", "");
assert!(resolve_proxy(&s).is_err());
}

#[test]
fn accepts_userinfo_in_url() {
let s = HttpProxySettings::from_parts(true, "http://alice:secret@proxy.local:8080", "", "");
assert!(resolve_proxy(&s).unwrap().is_some());
}

#[test]
fn accepts_explicit_basic_auth_fields() {
let s = HttpProxySettings::from_parts(
true,
"http://proxy.local:8080",
"alice",
"secret",
);
assert!(resolve_proxy(&s).unwrap().is_some());
}

#[test]
fn validation_error_none_when_disabled() {
let s = HttpProxySettings::from_parts(false, "bad", "", "");
assert!(validation_error(&s).is_none());
}

#[test]
fn validation_error_when_enabled_and_bad() {
let s = HttpProxySettings::from_parts(true, "ftp://x", "", "");
assert!(validation_error(&s).is_some());
}
}
2 changes: 2 additions & 0 deletions rust/src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod cost_pricing;
mod credential_migration;
mod hooks;
mod http;
mod http_proxy;
mod jsonl_scanner;
mod models_dev_pricing;
mod openai_dashboard;
Expand All @@ -23,6 +24,7 @@ pub use cost_pricing::*;
pub use credential_migration::*;
pub use hooks::*;
pub use http::*;
pub use http_proxy::*;
pub use jsonl_scanner::*;
pub use models_dev_pricing::*;
pub use openai_dashboard::*;
Expand Down
2 changes: 1 addition & 1 deletion rust/src/core/models_dev_pricing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -792,7 +792,7 @@ async fn refresh_unknown_models_at(
}

async fn refresh_catalog(now: SystemTime, cache_root: Option<&Path>) -> bool {
let Ok(client) = reqwest::Client::builder()
let Ok(client) = crate::core::apply_app_proxy(reqwest::Client::builder())
.timeout(Duration::from_secs(20))
.build()
else {
Expand Down
Loading
Loading