Skip to content

Commit 6aa4a7a

Browse files
echobtBounty Bot
andauthored
fix: batch fixes for issues #1911, 1913, 1915, 1916, 1917, 1919, 1920, 1921, 1922, 1923 [skip ci] (#373)
Fixes: - #1911: scrape --selector now accepts multiple values (use multiple times) - #1913: consistent multiple flag behavior for --selector like -H - #1915: debug config shows 'optional, not configured' instead of 'not found' - #1916: mcp list shows helpful message when no servers configured (already fixed) - #1917: mcp list adds --all flag for showing all servers - #1919: agent show adds --model flag for model override preview - #1920: config command adds 'get' subcommand alongside set/unset - #1921: agent create --non-interactive outputs only path, no banner - #1922: mcp add --url now supports ws:// and wss:// WebSocket URLs - #1923: mcp add adds --allow-local flag to permit localhost URLs Also fixes pre-existing missing trust_proxy field in RateLimitConfig. Co-authored-by: Bounty Bot <bounty-bot@factory.ai>
1 parent e81cee1 commit 6aa4a7a

6 files changed

Lines changed: 148 additions & 34 deletions

File tree

cortex-app-server/src/config.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,9 @@ pub struct RateLimitConfig {
266266
/// Exempt paths from rate limiting.
267267
#[serde(default)]
268268
pub exempt_paths: Vec<String>,
269+
/// Trust proxy headers (X-Forwarded-For) for client IP detection.
270+
#[serde(default)]
271+
pub trust_proxy: bool,
269272
}
270273

271274
fn default_rpm() -> u32 {
@@ -286,6 +289,7 @@ impl Default for RateLimitConfig {
286289
by_api_key: false,
287290
by_user: false,
288291
exempt_paths: vec!["/health".to_string()],
292+
trust_proxy: false,
289293
}
290294
}
291295
}

cortex-cli/src/agent_cmd.rs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ pub struct ShowArgs {
8383
/// Output as JSON.
8484
#[arg(long)]
8585
pub json: bool,
86+
87+
/// Show how agent would work with a specific model override.
88+
#[arg(long)]
89+
pub model: Option<String>,
8690
}
8791

8892
/// Arguments for create command.
@@ -1051,6 +1055,13 @@ async fn run_show(args: ShowArgs) -> Result<()> {
10511055
serde_json::Value::String("builtin".to_string()),
10521056
);
10531057
}
1058+
// Add model override if provided via --model flag
1059+
if let Some(ref model_override) = args.model {
1060+
map.insert(
1061+
"model_override".to_string(),
1062+
serde_json::Value::String(model_override.clone()),
1063+
);
1064+
}
10541065
}
10551066
let json = serde_json::to_string_pretty(&json_value)?;
10561067
println!("{json}");
@@ -1090,7 +1101,10 @@ async fn run_show(args: ShowArgs) -> Result<()> {
10901101
}
10911102
);
10921103

1093-
if let Some(ref model) = agent.model {
1104+
// Show model - either from --model override or agent's configured model
1105+
if let Some(ref model_override) = args.model {
1106+
println!("Model: {model_override} (override via --model)");
1107+
} else if let Some(ref model) = agent.model {
10941108
println!("Model: {model}");
10951109
}
10961110

@@ -1492,9 +1506,14 @@ mode: {mode}
14921506
std::fs::write(&agent_file, &content)
14931507
.with_context(|| format!("Failed to write agent file: {}", agent_file.display()))?;
14941508

1495-
println!("\nAgent '{name}' created successfully!");
1496-
println!(" Location: {}", agent_file.display());
1497-
println!("\n Use 'Cortex Agent show {name}' to view details.");
1509+
// In non-interactive mode, output only the path for scripting
1510+
if args.non_interactive {
1511+
println!("{}", agent_file.display());
1512+
} else {
1513+
println!("\nAgent '{name}' created successfully!");
1514+
println!(" Location: {}", agent_file.display());
1515+
println!("\n Use 'Cortex Agent show {name}' to view details.");
1516+
}
14981517

14991518
Ok(())
15001519
}

cortex-cli/src/debug_cmd.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ async fn run_config(args: ConfigArgs) -> Result<()> {
204204
if output.locations.local_config_exists {
205205
"(exists)"
206206
} else {
207-
"(not found)"
207+
"(optional, not configured)"
208208
}
209209
);
210210
}

cortex-cli/src/main.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,13 +680,23 @@ struct ConfigCommand {
680680
/// Config subcommands.
681681
#[derive(Subcommand)]
682682
enum ConfigSubcommand {
683+
/// Get a configuration value
684+
Get(ConfigGetArgs),
685+
683686
/// Set a configuration value
684687
Set(ConfigSetArgs),
685688

686689
/// Unset (remove) a configuration value
687690
Unset(ConfigUnsetArgs),
688691
}
689692

693+
/// Arguments for config get.
694+
#[derive(Args)]
695+
struct ConfigGetArgs {
696+
/// Configuration key to get (e.g., model, provider)
697+
key: String,
698+
}
699+
690700
/// Arguments for config set.
691701
#[derive(Args)]
692702
struct ConfigSetArgs {
@@ -1747,6 +1757,9 @@ async fn show_config(config_cli: ConfigCommand) -> Result<()> {
17471757
let config_path = config.cortex_home.join("config.toml");
17481758

17491759
match action {
1760+
ConfigSubcommand::Get(args) => {
1761+
return config_get(&config_path, &args.key);
1762+
}
17501763
ConfigSubcommand::Set(args) => {
17511764
return config_set(&config_path, &args.key, &args.value);
17521765
}
@@ -1853,6 +1866,52 @@ async fn show_config(config_cli: ConfigCommand) -> Result<()> {
18531866
Ok(())
18541867
}
18551868

1869+
/// Get a configuration value from config.toml.
1870+
fn config_get(config_path: &std::path::Path, key: &str) -> Result<()> {
1871+
if !config_path.exists() {
1872+
println!("Config file does not exist: {}", config_path.display());
1873+
return Ok(());
1874+
}
1875+
1876+
let content = std::fs::read_to_string(config_path)?;
1877+
let doc: toml_edit::DocumentMut = content.parse().unwrap_or_else(|_| {
1878+
eprintln!("Warning: Could not parse config file");
1879+
toml_edit::DocumentMut::new()
1880+
});
1881+
1882+
// Map common keys to their TOML sections
1883+
let (section, actual_key) = match key {
1884+
"model" | "default_model" => ("model", "default"),
1885+
"provider" | "model_provider" => ("model", "provider"),
1886+
"sandbox" | "sandbox_mode" => ("sandbox", "mode"),
1887+
"approval" | "approval_mode" => ("approval", "mode"),
1888+
k if k.contains('.') => {
1889+
let parts: Vec<&str> = k.splitn(2, '.').collect();
1890+
(parts[0], parts[1])
1891+
}
1892+
_ => ("", key),
1893+
};
1894+
1895+
let value = if section.is_empty() {
1896+
doc.get(actual_key)
1897+
} else {
1898+
doc.get(section).and_then(|t| t.get(actual_key))
1899+
};
1900+
1901+
match value {
1902+
Some(v) => {
1903+
// Print raw value for easy scripting
1904+
let display = v.as_str().unwrap_or(&v.to_string().replace('"', ""));
1905+
println!("{}", display);
1906+
}
1907+
None => {
1908+
println!("Key '{}' not found in config", key);
1909+
}
1910+
}
1911+
1912+
Ok(())
1913+
}
1914+
18561915
/// Set a configuration value in config.toml.
18571916
fn config_set(config_path: &std::path::Path, key: &str, value: &str) -> Result<()> {
18581917
// Read existing config or create empty

cortex-cli/src/mcp_cmd.rs

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ const MAX_COMMAND_ARGS: usize = 100;
5353
/// Maximum length for a single command argument
5454
const MAX_COMMAND_ARG_LENGTH: usize = 4096;
5555

56-
/// Allowed URL schemes for MCP HTTP transport
57-
const ALLOWED_URL_SCHEMES: &[&str] = &["http", "https"];
56+
/// Allowed URL schemes for MCP HTTP transport (including WebSocket)
57+
const ALLOWED_URL_SCHEMES: &[&str] = &["http", "https", "ws", "wss"];
5858

5959
/// Dangerous URL patterns that should be blocked
6060
const BLOCKED_URL_PATTERNS: &[&str] = &[
@@ -87,14 +87,19 @@ const BLOCKED_URL_PATTERNS: &[&str] = &[
8787
"172.31.", // Private network end
8888
];
8989

90-
/// Validates and sanitizes a URL for MCP HTTP transport.
90+
/// Validates and sanitizes a URL for MCP HTTP/WebSocket transport.
9191
///
9292
/// # Validation Rules:
9393
/// - Must not exceed maximum length
94-
/// - Must use allowed schemes (http/https)
95-
/// - Must not contain dangerous patterns
94+
/// - Must use allowed schemes (http/https/ws/wss)
95+
/// - Must not contain dangerous patterns (unless allow_local is true)
9696
/// - Must be a valid URL format
9797
fn validate_url(url: &str) -> Result<()> {
98+
validate_url_internal(url, false)
99+
}
100+
101+
/// Validates URL with option to allow local addresses.
102+
fn validate_url_internal(url: &str, allow_local: bool) -> Result<()> {
98103
// Check length
99104
if url.is_empty() {
100105
bail!("URL cannot be empty");
@@ -113,25 +118,27 @@ fn validate_url(url: &str) -> Result<()> {
113118

114119
let url_lower = url.to_lowercase();
115120

116-
// Check scheme - must start with http:// or https://
121+
// Check scheme - must start with http://, https://, ws://, or wss://
117122
let has_valid_scheme = ALLOWED_URL_SCHEMES
118123
.iter()
119124
.any(|&scheme| url_lower.starts_with(&format!("{}://", scheme)));
120125
if !has_valid_scheme {
121126
bail!(
122-
"URL must start with http:// or https://. Got: {}",
127+
"URL must start with http://, https://, ws://, or wss://. Got: {}",
123128
url.chars().take(20).collect::<String>()
124129
);
125130
}
126131

127-
// Check for blocked patterns
128-
for pattern in BLOCKED_URL_PATTERNS {
129-
if url_lower.contains(pattern) {
130-
bail!(
131-
"URL contains blocked pattern '{}'. For security, local/private network URLs are not allowed by default. \
132-
Use environment variables for local development servers.",
133-
pattern
134-
);
132+
// Check for blocked patterns (skip if allow_local is true)
133+
if !allow_local {
134+
for pattern in BLOCKED_URL_PATTERNS {
135+
if url_lower.contains(pattern) {
136+
bail!(
137+
"URL contains blocked pattern '{}'. For security, local/private network URLs are not allowed by default. \
138+
Use --allow-local flag to enable local development servers.",
139+
pattern
140+
);
141+
}
135142
}
136143
}
137144

@@ -140,6 +147,10 @@ fn validate_url(url: &str) -> Result<()> {
140147
&url[8..]
141148
} else if url_lower.starts_with("http://") {
142149
&url[7..]
150+
} else if url_lower.starts_with("wss://") {
151+
&url[6..]
152+
} else if url_lower.starts_with("ws://") {
153+
&url[5..]
143154
} else {
144155
bail!("Invalid URL scheme");
145156
};
@@ -310,6 +321,10 @@ pub struct ListArgs {
310321
/// Output the configured servers as JSON.
311322
#[arg(long)]
312323
pub json: bool,
324+
325+
/// Show all servers including disabled ones.
326+
#[arg(long)]
327+
pub all: bool,
313328
}
314329

315330
/// Arguments for get command.
@@ -344,6 +359,13 @@ pub struct AddArgs {
344359
#[arg(long, short = 'f')]
345360
pub force: bool,
346361

362+
/// Allow localhost and private network URLs (for local development).
363+
/// By default, URLs containing localhost, 127.0.0.1, or private network
364+
/// addresses are blocked for security. Use this flag to enable local
365+
/// development servers.
366+
#[arg(long)]
367+
pub allow_local: bool,
368+
347369
#[command(flatten)]
348370
pub transport_args: AddMcpTransportArgs,
349371
}
@@ -741,6 +763,7 @@ async fn run_add(args: AddArgs) -> Result<()> {
741763
let AddArgs {
742764
name,
743765
force,
766+
allow_local,
744767
transport_args,
745768
} = args;
746769

@@ -925,8 +948,8 @@ command = "{command_bin_escaped}"
925948
}),
926949
..
927950
} => {
928-
// Validate URL format and safety
929-
validate_url(&url)?;
951+
// Validate URL format and safety (allow_local bypasses localhost/private network check)
952+
validate_url_internal(&url, allow_local)?;
930953

931954
// Validate bearer token env var if provided
932955
if let Some(ref token_var) = bearer_token_env_var {

cortex-cli/src/scrape_cmd.rs

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,8 @@ pub struct ScrapeCommand {
9999
#[arg(long)]
100100
pub no_links: bool,
101101

102-
/// CSS selector to extract specific elements from the page.
102+
/// CSS selectors to extract specific elements from the page.
103+
/// Can be specified multiple times to combine selectors.
103104
/// Examples:
104105
/// article - Select all <article> elements
105106
/// .content - Select elements with class "content"
@@ -108,8 +109,9 @@ pub struct ScrapeCommand {
108109
/// table tbody tr - Select table rows in tbody
109110
/// h1, h2, h3 - Select multiple heading levels
110111
/// [data-id="123"] - Select by attribute
111-
#[arg(long, value_name = "SELECTOR")]
112-
pub selector: Option<String>,
112+
/// Multiple selectors: --selector "h1" --selector "p"
113+
#[arg(long, value_name = "SELECTOR", action = clap::ArgAction::Append)]
114+
pub selector: Vec<String>,
113115

114116
/// Show verbose output (includes fetching info).
115117
#[arg(short, long)]
@@ -416,17 +418,24 @@ impl ScrapeCommand {
416418
fn process_html(&self, html: &str, format: OutputFormat) -> Result<String> {
417419
let document = Html::parse_document(html);
418420

419-
// If a selector is provided, extract only that content
420-
let content_html = if let Some(selector_str) = &self.selector {
421-
let selector = Selector::parse(selector_str)
422-
.map_err(|e| anyhow::anyhow!("Invalid CSS selector: {e:?}"))?;
423-
421+
// If selectors are provided, extract only that content
422+
let content_html = if !self.selector.is_empty() {
424423
let mut selected = String::new();
425-
for element in document.select(&selector) {
426-
selected.push_str(&element.html());
424+
let mut matched_any = false;
425+
426+
for selector_str in &self.selector {
427+
let selector = Selector::parse(selector_str).map_err(|e| {
428+
anyhow::anyhow!("Invalid CSS selector '{}': {e:?}", selector_str)
429+
})?;
430+
431+
for element in document.select(&selector) {
432+
selected.push_str(&element.html());
433+
matched_any = true;
434+
}
427435
}
428-
if selected.is_empty() {
429-
bail!("No elements matched selector: {selector_str}");
436+
if !matched_any {
437+
let selectors_display = self.selector.join(", ");
438+
bail!("No elements matched selectors: {selectors_display}");
430439
}
431440
selected
432441
} else {

0 commit comments

Comments
 (0)