Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions crates/tinymemory-integrations/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<String, String>,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

priority high critique confident

Apply configured headers when building the engine

EngineSettings::headers is added to the public configuration and documented as being sent on every request, but the existing MemoryConfig::build path passes the settings to build_engine without any visible header application. The repository search shows this field is only declared here; the header-enabled API exists on CortexEngine, but no configuration code invokes it. As a result, a host can successfully deserialize and serialize headers while every request omits them, making the new configuration option ineffective. Forward the map through the registry/build path and call the engine's header configuration method, propagating invalid or reserved-header errors.

[RULE] unused-configuration ·

}

#[cfg(test)]
Expand Down
7 changes: 7 additions & 0 deletions crates/tinymemory-integrations/src/cortex/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions crates/tinymemory-integrations/src/cortex/engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<K, V>(
mut self,
headers: impl IntoIterator<Item = (K, V)>,
) -> Result<Self>
where
K: AsRef<str>,
V: AsRef<str>,
{
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 {
Expand Down
57 changes: 56 additions & 1 deletion crates/tinymemory-integrations/src/cortex/transport/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -59,6 +59,54 @@ 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<K, V>(pairs: impl IntoIterator<Item = (K, V)>) -> Result<HeaderMap>
where
K: AsRef<str>,
V: AsRef<str>,
{
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
.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"
)));
}
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 {
Expand Down Expand Up @@ -129,9 +177,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
Expand Down Expand Up @@ -166,6 +220,7 @@ impl HttpClient {
Ok(self
.inner
.request(method, url)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

priority medium e2e likely

Drive the running engine with configured headers end to end

The new fixed-headers feature has two external surfaces: the "headers" key in engine configuration (parsed through the registry) and the header itself riding every HTTP request to the CortexDB backend. The only coverage is unit-level: fixed_headers_ride_every_request_beside_the_credential inspects a built Request object, and fixed_headers_are_applied_and_a_credential_header_is_refused only checks that build_engine returns Ok/Err. Nothing runs the actual engine against the live harness (integration/cortexdb's docker-compose + mock_inference.py) and asserts the mock server received x-sdk-name on a request, or that a configured reserved header fails engine construction in a real startup path. The candidate lines in mock_inference.py (Content-Length, end_headers) are pre-existing plumbing of the mock server, not tests of this feature — a test would have to start the compose stack with an engine configured with headers, issue a request through the engine, and have mock_inference record and assert the header arrived. Until then, a wiring regression (e.g. headers dropped when the request builder is assembled, or the registry not threading settings.headers) would pass CI silently.

[RULE] e2e-uncovered ·

.headers(self.default_headers.clone())
.header(AUTHORIZATION, header))
}

Expand Down
37 changes: 37 additions & 0 deletions crates/tinymemory-integrations/src/cortex/transport/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -312,3 +312,40 @@ 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")])
.expect_err("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());
}
4 changes: 3 additions & 1 deletion crates/tinymemory-integrations/src/registry/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
30 changes: 30 additions & 0 deletions crates/tinymemory-integrations/src/registry/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use super::*;
fn settings(endpoint: Option<&str>) -> EngineSettings {
EngineSettings {
endpoint: endpoint.map(str::to_string),
..EngineSettings::default()
}
}

Expand Down Expand Up @@ -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"));
}
8 changes: 8 additions & 0 deletions docs/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading