Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ef01878
Update markdown streaming
ScriptSmith Mar 23, 2026
cc400fe
Remove unused system prompt
ScriptSmith Mar 23, 2026
8454257
Add openrouter category
ScriptSmith Mar 23, 2026
886ae3c
Unregister service workers when not enabled
ScriptSmith Mar 23, 2026
383fe34
Status indicator fixes
ScriptSmith Mar 23, 2026
6b8b784
Remove 2MB json limit
ScriptSmith Mar 24, 2026
fd2fe2b
Incude assistant responses in subsequent rounds
ScriptSmith Mar 24, 2026
8733dd0
Limit exa response sizes
ScriptSmith Mar 24, 2026
46794bd
Improve wizard
ScriptSmith Mar 24, 2026
66fbf13
Review changes
ScriptSmith Mar 24, 2026
da7f6c6
Handle single rounds and regeneration better
ScriptSmith Mar 25, 2026
bfd8b24
Use stream field helper
ScriptSmith Mar 25, 2026
c601776
Customise the number of rounds
ScriptSmith Mar 25, 2026
1164c54
Fix single-round rendering with small models
ScriptSmith Mar 25, 2026
6d5c700
Nicer toolsbar
ScriptSmith Mar 25, 2026
dfe2f73
Allow MCP servers in CSP
ScriptSmith Mar 25, 2026
ffd3118
Update default config to allow connecting to local providers (eg. oll…
ScriptSmith Mar 25, 2026
b8bc927
MCP artifacts
ScriptSmith Mar 25, 2026
e71c4c7
Fix MCP client connection stability and improve config modal UX
ScriptSmith Mar 25, 2026
72cde53
Simple MCP auth
ScriptSmith Mar 25, 2026
bf8c21f
Add truncate to tavily
ScriptSmith Mar 25, 2026
d8fc920
Upgrade testcontainers
ScriptSmith Mar 25, 2026
3792fc6
Review fixes
ScriptSmith Mar 25, 2026
72dd93a
Fix storbook errors
ScriptSmith Mar 25, 2026
7ffb45b
Enable server when added
ScriptSmith Mar 25, 2026
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
39 changes: 14 additions & 25 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,7 @@ rstest = "0.24"
serial_test = "3.2"
temp-env = "0.3"
tempfile = "3.23.0"
testcontainers-modules = { version = "0.14", features = ["postgres", "redis"] }
testcontainers-modules = { version = "0.15", features = ["postgres", "redis"] }
tokio-stream = "0.1.17"
wiremock = "0.6"

Expand Down
2 changes: 2 additions & 0 deletions docs/content/docs/configuration/providers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,12 @@ base_url = "https://openrouter.ai/api/v1"
# App attribution headers are sent automatically:
# HTTP-Referer: https://hadriangateway.com
# X-OpenRouter-Title: Hadrian Gateway
# X-OpenRouter-Categories: general-chat
# Override to customize, or set to "" to opt out:
# [providers.openrouter.headers]
# HTTP-Referer = "https://myapp.example.com"
# X-OpenRouter-Title = "My Application"
# X-OpenRouter-Categories = "coding-app"
```

**Ollama** (local, no API key needed):
Expand Down
24 changes: 12 additions & 12 deletions docs/content/docs/configuration/server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -140,25 +140,25 @@ permissions_policy = "geolocation=(), microphone=()"

### Default Content Security Policy

Hadrian ships a default CSP tailored for the web UI's frontend tools (Python, JavaScript, SQL, charts, Wikipedia, and Wikidata via WASM and external APIs):
Hadrian ships a default CSP tailored for the web UI's frontend tools and MCP server connections:

```
default-src 'self'; script-src 'self' blob: 'unsafe-eval' https://cdn.jsdelivr.net;
style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:;
connect-src 'self' https://cdn.jsdelivr.net https://*.wikipedia.org https://www.wikidata.org;
connect-src 'self' https: http: wss: ws:;
worker-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'
```

| Directive | Value | Reason |
| ------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `script-src` | `'self' blob: 'unsafe-eval' https://cdn.jsdelivr.net` | WASM workers as blob URLs; `unsafe-eval` for Pyodide bytecode execution and Vega `Function()` evaluation; CDN for Pyodide/DuckDB modules. |
| `style-src` | `'self' 'unsafe-inline'` | Tailwind CSS injects styles dynamically. |
| `worker-src` | `'self' blob:` | Web Workers run sandboxed code execution. |
| `frame-src` | `'self' blob:` | HTML artifact previews render in sandboxed iframes. |
| `img-src` | `'self' data: blob:` | Generated charts and inline images use data/blob URIs. |
| `connect-src` | `'self' https://cdn.jsdelivr.net https://*.wikipedia.org https://www.wikidata.org` | Pyodide/DuckDB fetch WASM and packages from CDN; Wikipedia and Wikidata tools query REST APIs. |
| `object-src` | `'none'` | Blocks plugin-based content (Flash, Java applets). |
| `base-uri` | `'self'` | Prevents `<base>` tag injection attacks. |
| Directive | Value | Reason |
| ------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `script-src` | `'self' blob: 'unsafe-eval' https://cdn.jsdelivr.net` | WASM workers as blob URLs; `unsafe-eval` for Pyodide bytecode execution and Vega `Function()` evaluation; CDN for Pyodide/DuckDB modules. |
| `style-src` | `'self' 'unsafe-inline'` | Tailwind CSS injects styles dynamically. |
| `worker-src` | `'self' blob:` | Web Workers run sandboxed code execution. |
| `frame-src` | `'self' blob:` | HTML artifact previews render in sandboxed iframes. |
| `img-src` | `'self' data: blob:` | Generated charts and inline images use data/blob URIs. |
| `connect-src` | `'self' https: http: wss: ws:` | MCP servers are user-configured at arbitrary URLs; also covers CDN fetches for Pyodide/DuckDB and REST API queries for frontend tools. |
| `object-src` | `'none'` | Blocks plugin-based content (Flash, Java applets). |
| `base-uri` | `'self'` | Prevents `<base>` tag injection attacks. |

Override this by setting `content_security_policy` explicitly. Set to an empty string to disable the CSP header entirely.

Expand Down
6 changes: 3 additions & 3 deletions docs/public/config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,7 @@
"max_response_body_bytes": 104857600,
"port": 8080,
"security_headers": {
"content_security_policy": "default-src 'self'; script-src 'self' blob: 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; connect-src 'self' https://cdn.jsdelivr.net https://*.wikipedia.org https://www.wikidata.org; worker-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'",
"content_security_policy": "default-src 'self'; script-src 'self' blob: 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; connect-src 'self' https: http: wss: ws:; worker-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'",
"content_type_options": "nosniff",
"enabled": true,
"frame_options": "DENY",
Expand Down Expand Up @@ -8011,7 +8011,7 @@
"properties": {
"content_security_policy": {
"description": "Content-Security-Policy header value. Controls resource loading to prevent XSS attacks.",
"default": "default-src 'self'; script-src 'self' blob: 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; connect-src 'self' https://cdn.jsdelivr.net https://*.wikipedia.org https://www.wikidata.org; worker-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'",
"default": "default-src 'self'; script-src 'self' blob: 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; connect-src 'self' https: http: wss: ws:; worker-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'",
"type": [
"string",
"null"
Expand Down Expand Up @@ -8302,7 +8302,7 @@
"security_headers": {
"description": "Security headers configuration.",
"default": {
"content_security_policy": "default-src 'self'; script-src 'self' blob: 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; connect-src 'self' https://cdn.jsdelivr.net https://*.wikipedia.org https://www.wikidata.org; worker-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'",
"content_security_policy": "default-src 'self'; script-src 'self' blob: 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; connect-src 'self' https: http: wss: ws:; worker-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'",
"content_type_options": "nosniff",
"enabled": true,
"frame_options": "DENY",
Expand Down
3 changes: 2 additions & 1 deletion src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2087,7 +2087,8 @@ pub fn build_app(config: &config::GatewayConfig, state: AppState) -> Router {
app = app.layer(cors_layer);
}

app.layer(TraceLayer::new_for_http())
app.layer(axum::extract::DefaultBodyLimit::disable())
.layer(TraceLayer::new_for_http())
.layer(RequestBodyLimitLayer::new(config.server.body_limit_bytes))
.with_state(state)
}
Expand Down
3 changes: 2 additions & 1 deletion src/bin/record_fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1920,7 +1920,8 @@ async fn record_fixture(
if def.provider == "openrouter" {
request = request
.header("HTTP-Referer", "https://hadriangateway.com")
.header("X-OpenRouter-Title", "Hadrian Gateway");
.header("X-OpenRouter-Title", "Hadrian Gateway")
.header("X-OpenRouter-Categories", "general-chat");
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ pub(crate) fn default_config_toml() -> &'static str {
[server]
host = "127.0.0.1"
port = 8080
# Allow providers on localhost (e.g. Ollama)
allow_loopback_urls = true

# CORS: Allow local development origins
[server.cors]
Expand Down
12 changes: 12 additions & 0 deletions src/config/features.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2194,6 +2194,13 @@ pub struct WebSearchConfig {
/// Lower than file_search since web search rarely needs multiple rounds.
#[serde(default = "default_web_search_max_iterations")]
pub max_iterations: usize,

/// Maximum characters of content text per search result.
/// Applies to Exa's `text.maxCharacters` parameter. Tavily returns concise
/// summaries by default so this is not needed there.
/// Set to 0 to disable (return full text). Default: 2000.
#[serde(default = "default_web_search_max_content_chars")]
pub max_content_chars: usize,
}

impl std::fmt::Debug for WebSearchConfig {
Expand All @@ -2208,6 +2215,7 @@ impl std::fmt::Debug for WebSearchConfig {
&self.cost_microcents_per_request,
)
.field("max_iterations", &self.max_iterations)
.field("max_content_chars", &self.max_content_chars)
.finish()
}
}
Expand Down Expand Up @@ -2237,6 +2245,10 @@ fn default_web_search_max_iterations() -> usize {
3
}

fn default_web_search_max_content_chars() -> usize {
2000
}

// ─────────────────────────────────────────────────────────────────────────────
// Web Fetch
// ─────────────────────────────────────────────────────────────────────────────
Expand Down
5 changes: 3 additions & 2 deletions src/config/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -606,8 +606,9 @@ pub struct OpenAiProviderConfig {
pub model_aliases: HashMap<String, String>,

/// Custom headers to include in requests.
/// For OpenRouter providers, `HTTP-Referer` and `X-OpenRouter-Title` are set
/// automatically for app attribution. Override here to customize or opt out.
/// For OpenRouter providers, `HTTP-Referer`, `X-OpenRouter-Title`, and
/// `X-OpenRouter-Categories` are set automatically for app attribution.
/// Override here to customize or opt out.
#[serde(default)]
pub headers: HashMap<String, String>,

Expand Down
7 changes: 4 additions & 3 deletions src/config/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -469,12 +469,13 @@ fn default_frame_options() -> Option<String> {
/// - `frame-src blob:` — HTML artifact preview iframes
/// - `img-src data: blob:` — Generated charts/images and inline assets
/// - `media-src blob:` — Audio playback from generated TTS blob URLs
/// - `connect-src https://cdn.jsdelivr.net https://*.wikipedia.org https://www.wikidata.org` —
/// Pyodide/DuckDB fetch WASM/packages from CDN; Wikipedia and Wikidata tools query REST APIs
/// - `connect-src https: http: wss: ws:` — MCP servers are user-configured at arbitrary URLs
/// and discovered at runtime (stored in localStorage), so connect-src must allow all schemes.
/// Also covers Pyodide/DuckDB CDN fetches and Wikipedia/Wikidata tool queries.
/// - `object-src 'none'` — Blocks plugins (Flash, Java applets)
/// - `base-uri 'self'` — Prevents `<base>` tag injection
fn default_csp() -> Option<String> {
Some("default-src 'self'; script-src 'self' blob: 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; connect-src 'self' https://cdn.jsdelivr.net https://*.wikipedia.org https://www.wikidata.org; worker-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'".to_string())
Some("default-src 'self'; script-src 'self' blob: 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; connect-src 'self' https: http: wss: ws:; worker-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'".to_string())
}
Comment on lines 477 to 479
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 CSP http: scheme allows arbitrary insecure connections

The new connect-src 'self' https: http: wss: ws: is much broader than the previous explicit allowlist. Including bare http: means the browser UI may send authenticated requests (with bearer tokens or cookies) to any plaintext HTTP endpoint, which is exploitable by a network-level attacker. The comment explains the motivation (user-configured MCP servers at arbitrary URLs), but it's worth considering a narrower alternative: keep https: / wss: for all remote endpoints and add http://localhost http://127.0.0.1 (or the loopback CIDR if supported) for local-only MCP servers. This would still serve the Ollama / local-MCP use case without opening the full http: scheme to the whole internet.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/config/server.rs
Line: 477-479

Comment:
**CSP `http:` scheme allows arbitrary insecure connections**

The new `connect-src 'self' https: http: wss: ws:` is much broader than the previous explicit allowlist. Including bare `http:` means the browser UI may send authenticated requests (with bearer tokens or cookies) to any plaintext HTTP endpoint, which is exploitable by a network-level attacker. The comment explains the motivation (user-configured MCP servers at arbitrary URLs), but it's worth considering a narrower alternative: keep `https:` / `wss:` for all remote endpoints and add `http://localhost http://127.0.0.1` (or the loopback CIDR if supported) for local-only MCP servers. This would still serve the Ollama / local-MCP use case without opening the full `http:` scheme to the whole internet.

How can I resolve this? If you propose a fix, please make it concise.


fn default_xss_protection() -> Option<String> {
Expand Down
3 changes: 3 additions & 0 deletions src/providers/open_ai/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ impl OpenAICompatibleProvider {
headers
.entry("X-OpenRouter-Title".to_string())
.or_insert_with(|| "Hadrian Gateway".to_string());
headers
.entry("X-OpenRouter-Categories".to_string())
.or_insert_with(|| "general-chat".to_string());
}

Self {
Expand Down
28 changes: 25 additions & 3 deletions src/routes/api/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,16 @@ struct ExaSearchRequest {

#[derive(Debug, Serialize)]
struct ExaContents {
text: bool,
text: ExaTextOptions,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ExaTextOptions {
/// Maximum characters of text to return per result.
/// Omit to return full text (Exa default).
#[serde(skip_serializing_if = "Option::is_none")]
max_characters: Option<usize>,
}

#[derive(Debug, Deserialize)]
Expand All @@ -103,6 +112,15 @@ pub async fn execute_web_search(
max_results: usize,
) -> Result<Vec<WebSearchResult>, WebSearchError> {
let timeout = std::time::Duration::from_secs(config.timeout_secs);
let max_chars = config.max_content_chars;

let truncate = |s: String| -> String {
if max_chars == 0 || s.len() <= max_chars {
return s;
}
let end = s.floor_char_boundary(max_chars);
format!("{}…[truncated]", &s[..end])
};

match config.provider {
WebSearchProvider::Tavily => {
Expand Down Expand Up @@ -140,7 +158,7 @@ pub async fn execute_web_search(
.map(|r| WebSearchResult {
title: r.title,
url: r.url,
content: r.content,
content: truncate(r.content),
score: r.score,
})
.collect())
Expand All @@ -149,7 +167,11 @@ pub async fn execute_web_search(
let req = ExaSearchRequest {
query: query.to_string(),
num_results: max_results,
contents: ExaContents { text: true },
contents: ExaContents {
text: ExaTextOptions {
max_characters: if max_chars > 0 { Some(max_chars) } else { None },
},
},
};
let resp = client
.post("https://api.exa.ai/search")
Expand Down
Loading
Loading