Skip to main content

lean_ctx/core/config/
proxy_upstream.rs

1// SPDX-License-Identifier: Apache-2.0
2use super::ResolvedProvider;
3
4/// The resolved provider upstreams a running proxy forwards to. Published
5/// to request handlers via a `tokio::sync::watch` channel so a config change is
6/// picked up live, without a proxy restart (#449).
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct Upstreams {
9    pub anthropic: String,
10    pub openai: String,
11    pub chatgpt: String,
12    pub gemini: String,
13    /// Registry providers from `[[proxy.providers]]` (universal framework),
14    /// validated and live-reloadable exactly like the built-ins.
15    pub providers: Vec<ResolvedProvider>,
16}
17
18impl Upstreams {
19    /// Look up a registry provider by id (`/providers/{id}/...` route, router
20    /// upstream overrides). Built-ins are not addressed here.
21    #[must_use]
22    pub fn provider_by_id(&self, id: &str) -> Option<&ResolvedProvider> {
23        self.providers.iter().find(|p| p.id == id)
24    }
25}
26
27#[derive(Debug, Clone, Copy)]
28pub enum ProxyProvider {
29    Anthropic,
30    OpenAi,
31    ChatGpt,
32    Gemini,
33}
34
35/// Why a running proxy's live upstream differs from what the operator expects.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum UpstreamDrift {
38    /// A `LEAN_CTX_*_UPSTREAM` env var is set in *this* process but the proxy
39    /// serves a different value — the env never reached the MCP/service-spawned
40    /// proxy. This is the #449 trap: Codex (and other MCP hosts) launch the
41    /// server with a stripped, allowlisted env that omits `LEAN_CTX_*_UPSTREAM`,
42    /// so the proxy it spawns never sees it. Fix: persist it to config.toml,
43    /// which the proxy reads live.
44    EnvNotApplied,
45    /// The proxy serves a value other than config.toml resolves to: it was
46    /// started with an env override that now masks a later config edit. Fix:
47    /// `lean-ctx proxy restart`.
48    ConfigNotApplied,
49}
50
51/// The `LEAN_CTX_*_UPSTREAM` override visible to *this* process for a provider,
52/// normalized (`None` if unset/blank). Lets status/doctor explain why an env var
53/// a user exported in their shell never reaches an MCP/service-spawned proxy.
54pub fn env_upstream_override(provider: ProxyProvider) -> Option<String> {
55    let var = match provider {
56        ProxyProvider::Anthropic => "LEAN_CTX_ANTHROPIC_UPSTREAM",
57        ProxyProvider::OpenAi => "LEAN_CTX_OPENAI_UPSTREAM",
58        ProxyProvider::ChatGpt => "LEAN_CTX_CHATGPT_UPSTREAM",
59        ProxyProvider::Gemini => "LEAN_CTX_GEMINI_UPSTREAM",
60    };
61    std::env::var(var).ok().and_then(|v| normalize_url_opt(&v))
62}
63
64/// Diagnose upstream drift for one provider from the CLI-visible env override
65/// (`env`), the config.toml value (`disk`) and the proxy's live value (`live`).
66/// `None` means in sync.
67pub fn diagnose_drift(env: Option<&str>, disk: &str, live: &str) -> Option<UpstreamDrift> {
68    if let Some(env) = env {
69        // An env override is present in this process: the proxy honours it only
70        // if it was started with it. If the proxy serves something else, the env
71        // never reached it (#449). If it matches, that is consistent (no drift).
72        return (env != live).then_some(UpstreamDrift::EnvNotApplied);
73    }
74    // No env override here: the proxy should mirror config.toml.
75    (disk != live).then_some(UpstreamDrift::ConfigNotApplied)
76}
77
78pub fn normalize_url(value: &str) -> String {
79    value.trim().trim_end_matches('/').to_string()
80}
81
82pub fn normalize_url_opt(value: &str) -> Option<String> {
83    let trimmed = normalize_url(value);
84    if trimmed.is_empty() {
85        None
86    } else {
87        Some(trimmed)
88    }
89}
90
91const ALLOWED_UPSTREAM_HOSTS: &[&str] = &[
92    "api.anthropic.com",
93    "api.openai.com",
94    "chatgpt.com",
95    "generativelanguage.googleapis.com",
96    // xAI Grok (OpenAI-compatible). Used by auto-seeded registry providers
97    // when `lean-ctx proxy enable` wires Grok (API-key rail + subscription rail).
98    "api.x.ai",
99    "cli-chat-proxy.grok.com",
100    // Command Code gateway. Used by the auto-seeded `commandcode` registry
101    // provider when `lean-ctx proxy enable` wires Command Code.
102    "api.commandcode.ai",
103];
104
105pub(in crate::core::config) fn validate_upstream_url(
106    url: &str,
107    allow_insecure_http: bool,
108    allow_custom_host: bool,
109) -> Result<String, String> {
110    let normalized = normalize_url(url);
111    // Loopback HTTP never leaves the machine — always allowed.
112    if is_local_proxy_url(&normalized) {
113        return Ok(normalized);
114    }
115
116    // A non-loopback plaintext `http://` upstream is reachable only through the
117    // explicit opt-in (#440). The old code rejected it on the HTTPS check *before*
118    // any override could apply, and pointed at `LEAN_CTX_ALLOW_CUSTOM_UPSTREAM`,
119    // which never lifted the scheme restriction. Handle it up front: the opt-in
120    // implies a deliberate custom host on a trusted local network, so it needs no
121    // separate allowlist check; otherwise give a hint that actually works.
122    if normalized.starts_with("http://") {
123        if allow_insecure_http {
124            return Ok(normalized);
125        }
126        return Err(format!(
127            "upstream URL must use HTTPS: {normalized} (for a trusted local-network HTTP \
128             upstream opt in with LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM=1 or \
129             `[proxy] allow_insecure_http_upstream = true`)"
130        ));
131    }
132    let Some(host_segment) = normalized.strip_prefix("https://") else {
133        return Err(format!(
134            "upstream URL must start with http:// or https://: {normalized}"
135        ));
136    };
137
138    let host = host_segment.split('/').next().unwrap_or("");
139    let host_no_port = host.split(':').next().unwrap_or(host);
140    if ALLOWED_UPSTREAM_HOSTS.contains(&host_no_port) || allow_custom_host {
141        Ok(normalized)
142    } else {
143        Err(format!(
144            "upstream host '{host_no_port}' not in allowlist {ALLOWED_UPSTREAM_HOSTS:?} (for a \
145             custom upstream host opt in with LEAN_CTX_ALLOW_CUSTOM_UPSTREAM=1 or \
146             `[proxy] allow_custom_upstream = true`)"
147        ))
148    }
149}
150
151/// True when `url` is an HTTPS upstream whose host is not in the built-in
152/// allowlist (and not loopback) — the case the `allow_custom_upstream` opt-in
153/// governs. Plaintext-HTTP custom hosts are governed by
154/// `allow_insecure_http_upstream` instead, so they are excluded here.
155pub(super) fn is_custom_upstream_host(url: &str) -> bool {
156    let n = normalize_url(url);
157    if is_local_proxy_url(&n) {
158        return false;
159    }
160    let Some(host_segment) = n.strip_prefix("https://") else {
161        return false;
162    };
163    let host = host_segment.split('/').next().unwrap_or("");
164    let host_no_port = host.split(':').next().unwrap_or(host);
165    !host_no_port.is_empty() && !ALLOWED_UPSTREAM_HOSTS.contains(&host_no_port)
166}
167
168pub fn is_local_proxy_url(value: &str) -> bool {
169    let n = normalize_url(value);
170    n.starts_with("http://127.0.0.1:")
171        || n.starts_with("http://localhost:")
172        || n.starts_with("http://[::1]:")
173}