From 78e242ad69265f3394eaee8c51eaf0e7625fa374 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 4 Oct 2026 19:27:50 +0300 Subject: [PATCH 1/6] chore: files changed crates/tinymemory-integrations/src/cortex/transport/mod.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/cortex/transport/mod.rs | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/crates/tinymemory-integrations/src/cortex/transport/mod.rs b/crates/tinymemory-integrations/src/cortex/transport/mod.rs index 6d6d61fd..f6d29680 100644 --- a/crates/tinymemory-integrations/src/cortex/transport/mod.rs +++ b/crates/tinymemory-integrations/src/cortex/transport/mod.rs @@ -17,7 +17,7 @@ mod failure; use std::time::Duration; -use reqwest::header::{AUTHORIZATION, HeaderValue}; +use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderName, HeaderValue}; use reqwest::{Method, RequestBuilder, Url}; use serde_json::Value; @@ -59,6 +59,51 @@ pub(crate) struct HttpClient { wire: CortexWire, read_backoff: Duration, actor: actor::ActorCache, + /// Fixed headers the host attaches to every request (see + /// [`default_headers`]). + default_headers: HeaderMap, +} + +/// Headers a host may not fix: the credential, the claim and actor this +/// transport sets itself, and what the HTTP stack owns. +const RESERVED_HEADERS: [&str; 7] = [ + "authorization", + "proxy-authorization", + "cookie", + "host", + "content-length", + "idempotency-key", + actor::ACTOR_HEADER, +]; + +/// The header map for `pairs`: fixed, non-credential headers a host sends +/// on every request, such as its product attribution (`x-sdk-name`). +/// +/// # Errors +/// +/// [`Error::Config`] for a name or value no header may carry, or a reserved +/// name (the credential, `Idempotency-Key`, the actor header, and what the +/// HTTP stack sets). No message carries a value. +pub(crate) fn default_headers(pairs: impl IntoIterator) -> Result +where + K: AsRef, + V: AsRef, +{ + let mut map = HeaderMap::new(); + for (name, value) in pairs { + let name = name.as_ref().trim(); + let parsed = HeaderName::from_bytes(name.as_bytes()) + .map_err(|_| Error::Config(format!("`{name}` is not a valid header name")))?; + if RESERVED_HEADERS.contains(&parsed.as_str()) { + return Err(Error::Config(format!( + "`{name}` is set by the memory transport and cannot be fixed" + ))); + } + let value = HeaderValue::from_str(value.as_ref().trim()) + .map_err(|_| Error::Config(format!("the `{name}` header value is not valid")))?; + map.insert(parsed, value); + } + Ok(map) } impl std::fmt::Debug for HttpClient { @@ -129,9 +174,15 @@ impl HttpClient { wire, read_backoff: READ_BACKOFF, actor: actor::ActorCache::default(), + default_headers: HeaderMap::new(), }) } + /// Sends `headers` on every request from now on. + pub(crate) fn set_default_headers(&mut self, headers: HeaderMap) { + self.default_headers = headers; + } + /// The wire this client speaks. pub(crate) fn wire(&self) -> CortexWire { self.wire @@ -166,6 +217,7 @@ impl HttpClient { Ok(self .inner .request(method, url) + .headers(self.default_headers.clone()) .header(AUTHORIZATION, header)) } From b72c1d0b02a05a997b63e17766df3752d0876bd0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 4 Oct 2026 19:28:07 +0300 Subject: [PATCH 2/6] chore: files changed crates/tinymemory-integrations/src/cortex/engine/mod.rs,crates/tinymemory-integ Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/cortex/engine/mod.rs | 22 +++++++++++++++++++ .../src/cortex/transport/mod.rs | 5 ++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/tinymemory-integrations/src/cortex/engine/mod.rs b/crates/tinymemory-integrations/src/cortex/engine/mod.rs index f14253ff..88b20c4c 100644 --- a/crates/tinymemory-integrations/src/cortex/engine/mod.rs +++ b/crates/tinymemory-integrations/src/cortex/engine/mod.rs @@ -105,6 +105,28 @@ impl CortexEngine { ) } + /// The same engine, sending `headers` on every request: fixed, + /// non-credential headers such as a host's product attribution + /// (`x-sdk-name`). Later calls replace earlier ones. + /// + /// # Errors + /// + /// [`Error::Config`] for an invalid header name or value, or a header the + /// transport sets itself (`Authorization`, `Idempotency-Key`, the actor + /// header, `Host`, `Cookie`, …). + pub fn with_default_headers( + mut self, + headers: impl IntoIterator, + ) -> Result + where + K: AsRef, + V: AsRef, + { + let map = crate::cortex::transport::default_headers(headers)?; + self.log.client.set_default_headers(map); + Ok(self) + } + /// Which HTTP surface this engine talks to. #[must_use] pub fn wire(&self) -> CortexWire { diff --git a/crates/tinymemory-integrations/src/cortex/transport/mod.rs b/crates/tinymemory-integrations/src/cortex/transport/mod.rs index f6d29680..af685cb7 100644 --- a/crates/tinymemory-integrations/src/cortex/transport/mod.rs +++ b/crates/tinymemory-integrations/src/cortex/transport/mod.rs @@ -94,7 +94,10 @@ where let name = name.as_ref().trim(); let parsed = HeaderName::from_bytes(name.as_bytes()) .map_err(|_| Error::Config(format!("`{name}` is not a valid header name")))?; - if RESERVED_HEADERS.contains(&parsed.as_str()) { + if RESERVED_HEADERS + .iter() + .any(|reserved| reserved.eq_ignore_ascii_case(parsed.as_str())) + { return Err(Error::Config(format!( "`{name}` is set by the memory transport and cannot be fixed" ))); From 686dcecab8224d419ac3d906079a38b523373068 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 4 Oct 2026 19:28:21 +0300 Subject: [PATCH 3/6] chore: files changed crates/tinymemory-integrations/src/config/mod.rs,crates/tinymemory-integrations Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-integrations/src/config/mod.rs | 5 +++++ crates/tinymemory-integrations/src/registry/mod.rs | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/tinymemory-integrations/src/config/mod.rs b/crates/tinymemory-integrations/src/config/mod.rs index 39620445..e8ccf69b 100644 --- a/crates/tinymemory-integrations/src/config/mod.rs +++ b/crates/tinymemory-integrations/src/config/mod.rs @@ -72,6 +72,11 @@ pub struct EngineSettings { /// The engine's base URL; `None` uses the engine's default endpoint. #[serde(default, skip_serializing_if = "Option::is_none")] pub endpoint: Option, + /// Fixed headers sent on every request, such as the host's product + /// attribution (`x-sdk-name`). Never a credential: the transport refuses + /// `Authorization` and the other headers it sets itself. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, } #[cfg(test)] diff --git a/crates/tinymemory-integrations/src/registry/mod.rs b/crates/tinymemory-integrations/src/registry/mod.rs index 564d95d3..fb885198 100644 --- a/crates/tinymemory-integrations/src/registry/mod.rs +++ b/crates/tinymemory-integrations/src/registry/mod.rs @@ -93,7 +93,9 @@ pub fn build_engine( ))); } }; - Ok(Arc::new(CortexEngine::new(wire, endpoint, credential)?)) + let engine = CortexEngine::new(wire, endpoint, credential)? + .with_default_headers(&settings.headers)?; + Ok(Arc::new(engine)) } #[cfg(test)] From be9dd26e03f6f15e8d79f10d08eb5eddf8af53c7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 4 Oct 2026 19:29:35 +0300 Subject: [PATCH 4/6] chore: files changed crates/tinymemory-integrations/src/cortex/transport/mod_tests.rs,crates/tinymem Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/cortex/transport/mod_tests.rs | 38 +++++++++++++++++++ .../src/registry/mod.rs | 4 +- .../src/registry/mod_tests.rs | 30 +++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/crates/tinymemory-integrations/src/cortex/transport/mod_tests.rs b/crates/tinymemory-integrations/src/cortex/transport/mod_tests.rs index f46b8377..1dcebe25 100644 --- a/crates/tinymemory-integrations/src/cortex/transport/mod_tests.rs +++ b/crates/tinymemory-integrations/src/cortex/transport/mod_tests.rs @@ -312,3 +312,41 @@ async fn the_hosted_wire_sends_no_actor() { "the backend names the actor" ); } + +#[test] +fn fixed_headers_refuse_reserved_and_malformed_entries() { + let map = default_headers([("x-sdk-name", "openhuman"), ("X-Product", "desktop")]).unwrap(); + assert_eq!(map.get("x-sdk-name").unwrap(), "openhuman"); + for reserved in [ + "authorization", + "Proxy-Authorization", + "cookie", + "host", + "Idempotency-Key", + "x-cortex-actor", + ] { + assert!( + matches!(default_headers([(reserved, "v")]), Err(Error::Config(_))), + "{reserved} must be refused" + ); + } + assert!(default_headers([("bad name", "v")]).is_err()); + let bad_value = default_headers([("x-sdk-name", "a\r\nX-Injected: 1")]) + .err() + .expect("a CR/LF value is refused"); + assert!(!bad_value.to_string().contains("Injected")); +} + +#[tokio::test] +async fn fixed_headers_ride_every_request_beside_the_credential() { + let mut c = client("https://example.test"); + c.set_default_headers(default_headers([("x-sdk-name", "openhuman")]).unwrap()); + let request = c + .request(Method::GET, "v1/events") + .await + .unwrap() + .build() + .unwrap(); + assert_eq!(request.headers().get("x-sdk-name").unwrap(), "openhuman"); + assert!(request.headers().get(AUTHORIZATION).unwrap().is_sensitive()); +} diff --git a/crates/tinymemory-integrations/src/registry/mod.rs b/crates/tinymemory-integrations/src/registry/mod.rs index fb885198..d12726a7 100644 --- a/crates/tinymemory-integrations/src/registry/mod.rs +++ b/crates/tinymemory-integrations/src/registry/mod.rs @@ -93,8 +93,8 @@ pub fn build_engine( ))); } }; - let engine = CortexEngine::new(wire, endpoint, credential)? - .with_default_headers(&settings.headers)?; + let engine = + CortexEngine::new(wire, endpoint, credential)?.with_default_headers(&settings.headers)?; Ok(Arc::new(engine)) } diff --git a/crates/tinymemory-integrations/src/registry/mod_tests.rs b/crates/tinymemory-integrations/src/registry/mod_tests.rs index 86203f10..70d97f98 100644 --- a/crates/tinymemory-integrations/src/registry/mod_tests.rs +++ b/crates/tinymemory-integrations/src/registry/mod_tests.rs @@ -7,6 +7,7 @@ use super::*; fn settings(endpoint: Option<&str>) -> EngineSettings { EngineSettings { endpoint: endpoint.map(str::to_string), + ..EngineSettings::default() } } @@ -142,3 +143,32 @@ fn credential_debug_never_shows_the_token() { "EngineCredential::None" ); } + +#[test] +fn fixed_headers_are_applied_and_a_credential_header_is_refused() { + let mut with_headers = settings(Some("https://cortex.example.test")); + with_headers + .headers + .insert("x-sdk-name".to_string(), "openhuman".to_string()); + assert!( + build_engine( + CORTEXDB_ENGINE_ID, + &with_headers, + EngineCredential::Static("ctx_key".to_string()) + ) + .is_ok() + ); + + with_headers + .headers + .insert("Authorization".to_string(), "Bearer smuggled".to_string()); + let refused = build_engine( + CORTEXDB_ENGINE_ID, + &with_headers, + EngineCredential::Static("ctx_key".to_string()), + ) + .err() + .expect("a credential header is refused"); + assert!(matches!(refused, Error::Config(_)), "{refused:?}"); + assert!(!refused.to_string().contains("smuggled")); +} From 07b5d0167072fce1488e4576a30aeb8ae8c2b32c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 4 Oct 2026 19:29:52 +0300 Subject: [PATCH 5/6] chore: files changed crates/tinymemory-integrations/src/cortex/transport/mod_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinymemory-integrations/src/cortex/transport/mod_tests.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/tinymemory-integrations/src/cortex/transport/mod_tests.rs b/crates/tinymemory-integrations/src/cortex/transport/mod_tests.rs index 1dcebe25..b2e05824 100644 --- a/crates/tinymemory-integrations/src/cortex/transport/mod_tests.rs +++ b/crates/tinymemory-integrations/src/cortex/transport/mod_tests.rs @@ -332,8 +332,7 @@ fn fixed_headers_refuse_reserved_and_malformed_entries() { } assert!(default_headers([("bad name", "v")]).is_err()); let bad_value = default_headers([("x-sdk-name", "a\r\nX-Injected: 1")]) - .err() - .expect("a CR/LF value is refused"); + .expect_err("a CR/LF value is refused"); assert!(!bad_value.to_string().contains("Injected")); } From 19a5e185d7c088585143653c07d21ecb3bc1b362 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 4 Oct 2026 19:30:16 +0300 Subject: [PATCH 6/6] chore: files changed crates/tinymemory-integrations/src/cortex/README.md,docs/integration.md Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymemory-integrations/src/cortex/README.md | 7 +++++++ docs/integration.md | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/crates/tinymemory-integrations/src/cortex/README.md b/crates/tinymemory-integrations/src/cortex/README.md index 582b3732..8464e5bb 100644 --- a/crates/tinymemory-integrations/src/cortex/README.md +++ b/crates/tinymemory-integrations/src/cortex/README.md @@ -203,6 +203,13 @@ These were measured against a live CortexDB by the v1 adapter. The doubles in ## Transport +- A host may fix non-credential headers on every request + (`CortexEngine::with_default_headers`, or `EngineSettings::headers` through + the registry), such as the `x-sdk-name` attribution the TinyHumans backend + expects. Reserved headers (`Authorization`, `Proxy-Authorization`, `Cookie`, + `Host`, `Content-Length`, `Idempotency-Key`, `X-Cortex-Actor`) are refused + with `Error::Config`, and no refusal echoes a value. + - Credentialed cleartext endpoints that are not loopback are refused with `Error::Config`. - The bearer is resolved on every attempt and sent in a header marked diff --git a/docs/integration.md b/docs/integration.md index 92754fdf..fbe6e9c8 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -76,6 +76,14 @@ let config: MemoryConfig = serde_json::from_value(serde_json::json!({ let engine = config.build(EngineCredential::Static(key))?; ``` +A host that must attribute its requests (the TinyHumans backend expects an +`x-sdk-name` header naming the product) fixes them per engine, in +configuration (`"engines": { "tinyhumans": { "headers": { "x-sdk-name": +"my-product" } } }`) or with `CortexEngine::with_default_headers`. They ride +every request; the transport refuses `Authorization`, `Idempotency-Key`, the +actor header and the headers the HTTP stack sets, so the credential stays +the `EngineCredential`'s alone. + | Engine id | Where it runs | Consolidation (belief builds) | | --- | --- | --- | | `cortexdb` | a CortexDB server, `v1/*` routes | on demand: `v1/beliefs/build`, built within the request |