1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5use super::ConfigError;
6use crate::process::guard::SpawnGuard;
7use crate::process::stdio::StdioProcessSpec;
8use crate::security::redact::{is_sensitive_key, redact_stdio_args, redact_url};
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct UpstreamConfig {
12 pub name: String,
13 #[serde(default = "default_true")]
14 pub enabled: bool,
15 #[serde(default)]
16 pub url: Option<String>,
17 #[serde(default)]
18 pub bearer_token_env: Option<String>,
19 #[serde(default)]
20 pub command: Option<String>,
21 #[serde(default)]
22 pub args: Vec<String>,
23 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
24 pub env: BTreeMap<String, String>,
25 #[serde(default)]
26 pub oauth: Option<GatewayUpstreamOauthConfig>,
27 #[serde(default = "default_true")]
28 pub proxy_resources: bool,
29 #[serde(default = "default_true")]
30 pub proxy_prompts: bool,
31 #[serde(default)]
32 pub expose_tools: Option<Vec<String>>,
33 #[serde(default)]
34 pub expose_resources: Option<Vec<String>>,
35 #[serde(default)]
36 pub expose_prompts: Option<Vec<String>>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct UpstreamConfigView {
41 pub name: String,
42 pub enabled: bool,
43 pub url: Option<String>,
44 pub bearer_token_env: Option<String>,
45 pub command: Option<String>,
46 pub args: Vec<String>,
47 pub env_keys: Vec<String>,
48 pub oauth_enabled: bool,
49 pub proxy_resources: bool,
50 pub proxy_prompts: bool,
51 pub expose_tools: Option<Vec<String>>,
52 pub expose_resources: Option<Vec<String>>,
53 pub expose_prompts: Option<Vec<String>>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct GatewayUpstreamOauthConfig {
58 pub mode: GatewayUpstreamOauthMode,
59 pub registration: GatewayUpstreamOauthRegistration,
60 #[serde(default)]
61 pub scopes: Option<Vec<String>>,
62 #[serde(default)]
63 pub prefer_client_metadata_document: Option<bool>,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum GatewayUpstreamOauthMode {
69 AuthorizationCodePkce,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(tag = "strategy", rename_all = "snake_case")]
74pub enum GatewayUpstreamOauthRegistration {
75 ClientMetadataDocument {
76 url: String,
77 },
78 Preregistered {
79 client_id: String,
80 #[serde(default)]
81 client_secret_env: Option<String>,
82 },
83 Dynamic,
84}
85
86impl Default for UpstreamConfig {
87 fn default() -> Self {
88 Self {
89 name: String::new(),
90 enabled: true,
91 url: None,
92 bearer_token_env: None,
93 command: None,
94 args: Vec::new(),
95 env: BTreeMap::new(),
96 oauth: None,
97 proxy_resources: true,
98 proxy_prompts: true,
99 expose_tools: None,
100 expose_resources: None,
101 expose_prompts: None,
102 }
103 }
104}
105
106impl UpstreamConfig {
107 pub fn validate(&self) -> Result<(), ConfigError> {
108 validate_name(&self.name)?;
109 validate_transport_shape(self)?;
110 if let Some(name) = self.bearer_token_env.as_deref() {
111 validate_bearer_token_env(name)?;
112 }
113 if let Some(url) = self.url.as_deref() {
114 let parsed = url::Url::parse(url)
115 .map_err(|_| ConfigError::invalid("url", "must be a valid URL"))?;
116 match parsed.scheme() {
117 "http" | "https" | "ws" | "wss" => {}
118 _ => {
119 return Err(ConfigError::invalid(
120 "url",
121 "must use http, https, ws, or wss",
122 ));
123 }
124 }
125 }
126 if let Some(oauth) = &self.oauth {
127 oauth.validate()?;
128 if self.url.is_none() {
129 return Err(ConfigError::invalid("oauth", "requires upstream url"));
130 }
131 }
132 Ok(())
133 }
134
135 #[must_use]
136 pub fn redacted_view(&self) -> UpstreamConfigView {
137 UpstreamConfigView {
138 name: self.name.clone(),
139 enabled: self.enabled,
140 url: self.url.as_deref().map(redact_url),
141 bearer_token_env: self
142 .bearer_token_env
143 .as_ref()
144 .map(|_| "[redacted]".to_owned()),
145 command: self.command.clone(),
146 args: redact_stdio_args(&self.args),
147 env_keys: self
148 .env
149 .keys()
150 .map(|key| {
151 if is_sensitive_key(key) {
152 "[redacted]".to_owned()
153 } else {
154 key.clone()
155 }
156 })
157 .collect(),
158 oauth_enabled: self.oauth.is_some(),
159 proxy_resources: self.proxy_resources,
160 proxy_prompts: self.proxy_prompts,
161 expose_tools: self.expose_tools.clone(),
162 expose_resources: self.expose_resources.clone(),
163 expose_prompts: self.expose_prompts.clone(),
164 }
165 }
166}
167
168fn validate_transport_shape(config: &UpstreamConfig) -> Result<(), ConfigError> {
169 let has_url = config
170 .url
171 .as_deref()
172 .map(str::trim)
173 .is_some_and(|url| !url.is_empty());
174 let has_command = config
175 .command
176 .as_deref()
177 .map(str::trim)
178 .is_some_and(|command| !command.is_empty());
179 match (has_url, has_command) {
180 (true, true) => {
181 return Err(ConfigError::invalid(
182 "transport",
183 "must specify either url or command, not both",
184 ));
185 }
186 (false, false) => {
187 return Err(ConfigError::invalid(
188 "transport",
189 "must specify exactly one of url or command",
190 ));
191 }
192 _ => {}
193 }
194 if has_command {
195 let spec = StdioProcessSpec {
196 command: config.command.clone().unwrap_or_default(),
197 args: config.args.clone(),
198 env: config.env.clone(),
199 };
200 spec.validate(&SpawnGuard::default())
201 .map_err(|_| ConfigError::invalid("command", "stdio command is not allowed"))?;
202 }
203 Ok(())
204}
205
206impl GatewayUpstreamOauthConfig {
207 fn validate(&self) -> Result<(), ConfigError> {
208 if let Some(scopes) = &self.scopes {
209 if scopes.iter().any(|scope| scope.trim().is_empty()) {
210 return Err(ConfigError::invalid(
211 "oauth.scopes",
212 "must not contain blanks",
213 ));
214 }
215 }
216 if let GatewayUpstreamOauthRegistration::Preregistered {
217 client_secret_env: Some(env),
218 ..
219 } = &self.registration
220 {
221 validate_bearer_token_env(env)?;
222 }
223 Ok(())
224 }
225}
226
227pub fn default_true() -> bool {
228 true
229}
230
231fn validate_name(name: &str) -> Result<(), ConfigError> {
232 if name.trim().is_empty() {
233 return Err(ConfigError::invalid("name", "must not be empty"));
234 }
235 if !name
236 .chars()
237 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
238 {
239 return Err(ConfigError::invalid(
240 "name",
241 "must contain only ASCII letters, digits, hyphens, underscores, and dots",
242 ));
243 }
244 Ok(())
245}
246
247pub fn validate_bearer_token_env(value: &str) -> Result<(), ConfigError> {
248 let trimmed = value.trim();
249 let looks_like_secret = trimmed.starts_with("Bearer ")
250 || trimmed.starts_with("sk-")
251 || trimmed.starts_with("ghp_")
252 || trimmed.starts_with("github_pat_")
253 || looks_like_jwt(trimmed);
254 if looks_like_secret {
255 return Err(ConfigError::invalid(
256 "bearer_token_env",
257 "must be an environment variable name, not a token value",
258 ));
259 }
260 let mut chars = trimmed.chars();
261 let Some(first) = chars.next() else {
262 return Err(ConfigError::invalid(
263 "bearer_token_env",
264 "must not be empty",
265 ));
266 };
267 if !(first == '_' || first.is_ascii_uppercase()) {
268 return Err(ConfigError::invalid(
269 "bearer_token_env",
270 "must start with an uppercase ASCII letter or underscore",
271 ));
272 }
273 if !chars.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit()) {
274 return Err(ConfigError::invalid(
275 "bearer_token_env",
276 "must contain only uppercase ASCII letters, digits, and underscores",
277 ));
278 }
279 Ok(())
280}
281
282fn looks_like_jwt(value: &str) -> bool {
283 let parts: Vec<&str> = value.split('.').collect();
284 parts.len() == 3 && parts.iter().all(|part| part.len() >= 8)
285}
286
287#[cfg(test)]
288#[path = "config_tests.rs"]
289mod tests;