Skip to main content

contextgraph_host/
http.rs

1//! Streamable-HTTP transport: a remote Context Graph Protocol provider reached by POSTing the
2//! envelope to its URL (`SPEC.md` §3 "remote providers:
3//! streamable HTTP"). The reference host uses request/response JSON — the
4//! [`Envelope`] as the POST body, one [`Envelope`] back as the response body
5//! — which any streamable-HTTP server satisfies; chunked frame streaming is
6//! a documented forward extension, not needed for the v1 shape.
7//!
8//! Unlike stdio's process isolation, an HTTP provider is remote by nature, so
9//! its `egress` posture is decided by the URL host and gated through the same
10//! [`crate::consent`] store at the [`crate::host::Host`] layer.
11
12use std::fmt;
13use std::net::IpAddr;
14use std::time::Duration;
15
16use async_trait::async_trait;
17use contextgraph_types::{
18    Capabilities, ContextQuery, ContextQueryResult, PROTOCOL_VERSION, ProviderInfo, VerifyRequest,
19    VerifyResponse,
20};
21
22use crate::error::HostError;
23use crate::provider::ContextProvider;
24use crate::wire::{
25    Envelope, envelope_kind, next_correlation_id, verify_correlation, versions_compatible,
26};
27
28/// Total per-request budget for an HTTP exchange (handshake or query).
29const HTTP_TIMEOUT: Duration = Duration::from_secs(30);
30
31/// A bearer credential a host uses to authenticate to a remote provider.
32///
33/// The secret is **never** rendered: both [`Debug`](fmt::Debug) and
34/// [`Display`](fmt::Display) print the fixed placeholder `Credential(<redacted>)`,
35/// so a credential that reaches a log line, an `{:?}`/`{}` interpolation, or a
36/// panic payload cannot spill its bytes (`SPEC.md` §4.2, **C8**). The only way
37/// to read the raw value is [`Credential::expose`], a crate-private method used
38/// solely to attach the header on the wire — a leak is therefore greppable.
39#[derive(Clone)]
40pub struct Credential {
41    /// The bearer token / `Authorization` value. Deliberately unexposed to any
42    /// formatting impl.
43    token: String,
44}
45
46impl Credential {
47    /// Wrap a bearer token. It is attached as `Authorization: Bearer <token>`
48    /// on every request this provider sends and is never logged (C8).
49    pub fn bearer(token: impl Into<String>) -> Self {
50        Self {
51            token: token.into(),
52        }
53    }
54
55    /// The raw secret — the single, greppable exit point, used only to set the
56    /// `Authorization` header on the wire.
57    fn expose(&self) -> &str {
58        &self.token
59    }
60}
61
62/// C8: a credential in a `{:?}` rendering (a log line, a panic payload) prints a
63/// fixed placeholder, never its bytes.
64impl fmt::Debug for Credential {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        f.write_str("Credential(<redacted>)")
67    }
68}
69
70/// C8: a credential in a `{}` rendering prints the same fixed placeholder — so
71/// even an accidental `Display` interpolation cannot leak the secret.
72impl fmt::Display for Credential {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        f.write_str("Credential(<redacted>)")
75    }
76}
77
78/// Whether a URL host component names the loopback interface — the one case an
79/// unencrypted (`http://`) transport is allowed, because the bytes never leave
80/// the machine (`SPEC.md` §4.2, **C7**). Mirrors the `localhost` exception
81/// [`verify::file_uri_to_path`](crate::verify) makes for `file://`, widened to
82/// the loopback IP ranges: the literal name `localhost`, `127.0.0.0/8`, and
83/// `::1`. IPv6 hosts arrive bracketed (`[::1]`) from a URL, so the brackets are
84/// stripped before parsing.
85fn is_loopback_host(host: &str) -> bool {
86    if host.eq_ignore_ascii_case("localhost") {
87        return true;
88    }
89    let bare = host
90        .strip_prefix('[')
91        .and_then(|inner| inner.strip_suffix(']'))
92        .unwrap_or(host);
93    // `IpAddr::is_loopback` is exactly `127.0.0.0/8` for v4 and `::1` for v6.
94    matches!(bare.parse::<IpAddr>(), Ok(ip) if ip.is_loopback())
95}
96
97/// Refuse a plaintext transport to a non-loopback provider **before** any bytes
98/// leave the host (`SPEC.md` §4.2, **C7**): an `http://` (not `https://`) URL
99/// whose host is not loopback would carry the query payload — and any bearer
100/// credential — across the network in cleartext. A loopback `http://` target is
101/// allowed (the bytes never leave the machine); every `https://` target is
102/// allowed. Called before the client is built or DNS is resolved, so a refusal
103/// short-circuits with zero network activity.
104fn refuse_insecure_transport(id: &str, url: &str) -> Result<(), HostError> {
105    let parsed = reqwest::Url::parse(url).map_err(|e| HostError::Transport {
106        id: id.to_string(),
107        message: format!("invalid provider url: {e}"),
108    })?;
109    if parsed.scheme() == "http" {
110        let host = parsed.host_str().unwrap_or("");
111        if !is_loopback_host(host) {
112            return Err(HostError::InsecureTransport {
113                id: id.to_string(),
114                host: host.to_string(),
115            });
116        }
117    }
118    Ok(())
119}
120
121/// A [`ContextProvider`] backed by a remote HTTP endpoint. Handshakes once on
122/// [`HttpProvider::connect`] and caches the negotiated identity + capabilities.
123pub struct HttpProvider {
124    id: String,
125    url: String,
126    client: reqwest::Client,
127    info: ProviderInfo,
128    capabilities: Capabilities,
129    /// Bearer credential attached to every request, if the provider requires
130    /// one. Redacted from every rendering (C8).
131    credential: Option<Credential>,
132}
133
134impl HttpProvider {
135    /// Connect to a remote provider with no credential — a thin back-compat
136    /// wrapper over [`connect_with_auth`](Self::connect_with_auth). POST a
137    /// `handshake`, expect a compatible `handshake_ack`, and cache its identity
138    /// + capabilities. `id` is the host-facing routing/consent key.
139    pub async fn connect(id: impl Into<String>, url: impl Into<String>) -> Result<Self, HostError> {
140        Self::connect_with_auth(id, url, None).await
141    }
142
143    /// Connect to a remote provider, optionally attaching a bearer
144    /// [`Credential`] to every request. Enforces transport security before any
145    /// bytes leave the host: a plaintext (`http://`) transport to a non-loopback
146    /// provider is refused with [`HostError::InsecureTransport`], so neither the
147    /// handshake nor a credential ever crosses the network in cleartext
148    /// (`SPEC.md` §4.2, **C7**).
149    pub async fn connect_with_auth(
150        id: impl Into<String>,
151        url: impl Into<String>,
152        credential: Option<Credential>,
153    ) -> Result<Self, HostError> {
154        let id = id.into();
155        let url = url.into();
156        // C7 first, before the client is built or DNS is resolved: a refusal
157        // must short-circuit with zero network activity so no payload leaks.
158        refuse_insecure_transport(&id, &url)?;
159        let client = reqwest::Client::builder()
160            .timeout(HTTP_TIMEOUT)
161            .build()
162            .map_err(|e| HostError::Transport {
163                id: id.clone(),
164                message: format!("building HTTP client: {e}"),
165            })?;
166
167        let ack = post_envelope(
168            &client,
169            &url,
170            &Envelope::Handshake {
171                protocol_version: PROTOCOL_VERSION.to_string(),
172            },
173            &id,
174            credential.as_ref(),
175        )
176        .await?;
177
178        match ack {
179            Envelope::HandshakeAck {
180                protocol_version,
181                provider,
182                capabilities,
183            } => {
184                if !versions_compatible(PROTOCOL_VERSION, &protocol_version) {
185                    return Err(HostError::VersionMismatch {
186                        host: PROTOCOL_VERSION.to_string(),
187                        provider: provider.name,
188                        provider_version: protocol_version,
189                    });
190                }
191                // An HTTP transport is egress by definition: every query is
192                // POSTed off-box to a remote URL. So the consent gate must key
193                // off transport, not the remote's self-report — a remote that
194                // handshakes `egress:false` would otherwise be queried with no
195                // consent. Force it on here regardless of what it declared.
196                // (The stdio path keeps its declared posture; a local child
197                // that doesn't reach the network genuinely may not be egress.)
198                let mut info = provider;
199                info.data_flow.egress = true;
200                Ok(Self {
201                    id,
202                    url,
203                    client,
204                    info,
205                    capabilities,
206                    credential,
207                })
208            }
209            other => Err(HostError::UnexpectedEnvelope {
210                id,
211                expected: "handshake_ack".into(),
212                got: envelope_kind(&other).into(),
213            }),
214        }
215    }
216}
217
218/// POST one envelope to the provider URL and decode the response as one
219/// envelope. A non-2xx status or a non-envelope body is a clean named error,
220/// never a panic (task deliverable 5).
221///
222/// When `credential` is present it is attached as `Authorization: Bearer …` via
223/// reqwest's [`bearer_auth`](reqwest::RequestBuilder::bearer_auth) — never a
224/// format string that could leak the secret into a log (C8).
225async fn post_envelope(
226    client: &reqwest::Client,
227    url: &str,
228    env: &Envelope,
229    id: &str,
230    credential: Option<&Credential>,
231) -> Result<Envelope, HostError> {
232    let mut request = client.post(url).json(env);
233    if let Some(credential) = credential {
234        request = request.bearer_auth(credential.expose());
235    }
236    let response = request.send().await.map_err(|e| HostError::Transport {
237        id: id.to_string(),
238        message: e.to_string(),
239    })?;
240
241    // A rejected credential is its own named error, distinct from any other
242    // transport failure — and it names only the id + status, never the
243    // credential (C8).
244    if response.status() == reqwest::StatusCode::UNAUTHORIZED {
245        return Err(HostError::Unauthorized { id: id.to_string() });
246    }
247
248    if !response.status().is_success() {
249        let status = response.status();
250        let body = response.text().await.unwrap_or_default();
251        return Err(HostError::Transport {
252            id: id.to_string(),
253            message: format!("HTTP {status}: {body}"),
254        });
255    }
256
257    response.json::<Envelope>().await.map_err(|e| {
258        HostError::Wire(format!(
259            "provider {id} returned a non-envelope HTTP body: {e}"
260        ))
261    })
262}
263
264#[async_trait]
265impl ContextProvider for HttpProvider {
266    fn id(&self) -> &str {
267        &self.id
268    }
269
270    fn info(&self) -> &ProviderInfo {
271        &self.info
272    }
273
274    fn capabilities(&self) -> &Capabilities {
275        &self.capabilities
276    }
277
278    async fn query(&self, query: &ContextQuery) -> Result<ContextQueryResult, HostError> {
279        let sent_id = self.capabilities.correlation.then(next_correlation_id);
280        let reply = post_envelope(
281            &self.client,
282            &self.url,
283            &Envelope::Query {
284                id: sent_id.clone(),
285                query: query.clone(),
286            },
287            &self.id,
288            self.credential.as_ref(),
289        )
290        .await?;
291        match reply {
292            Envelope::Frames { id: echoed, result } => {
293                verify_correlation(&self.id, sent_id.as_deref(), echoed.as_deref())?;
294                Ok(result)
295            }
296            Envelope::Error { message, code, .. } => Err(HostError::Provider {
297                id: self.id.clone(),
298                code,
299                message,
300            }),
301            other => Err(HostError::UnexpectedEnvelope {
302                id: self.id.clone(),
303                expected: "frames".into(),
304                got: envelope_kind(&other).into(),
305            }),
306        }
307    }
308
309    async fn verify(&self, request: &VerifyRequest) -> Result<VerifyResponse, HostError> {
310        let reply = post_envelope(
311            &self.client,
312            &self.url,
313            &Envelope::Verify {
314                request: request.clone(),
315            },
316            &self.id,
317            self.credential.as_ref(),
318        )
319        .await?;
320        match reply {
321            Envelope::Verified { response } => Ok(response),
322            Envelope::Error { message, code, .. } => Err(HostError::Provider {
323                id: self.id.clone(),
324                code,
325                message,
326            }),
327            other => Err(HostError::UnexpectedEnvelope {
328                id: self.id.clone(),
329                expected: "verified".into(),
330                got: envelope_kind(&other).into(),
331            }),
332        }
333    }
334
335    async fn shutdown(&self) -> Result<(), HostError> {
336        // Best-effort teardown notice; a remote endpoint is not ours to reap.
337        let _ = post_envelope(
338            &self.client,
339            &self.url,
340            &Envelope::Shutdown,
341            &self.id,
342            self.credential.as_ref(),
343        )
344        .await;
345        Ok(())
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use contextgraph_types::capability::QueryCapability;
353    use contextgraph_types::{ContextFrame, DataFlow, FrameKind};
354    use wiremock::matchers::{header, method};
355    use wiremock::{Mock, MockServer, ResponseTemplate};
356
357    fn ack_body(version: &str) -> serde_json::Value {
358        serde_json::to_value(Envelope::HandshakeAck {
359            protocol_version: version.to_string(),
360            provider: ProviderInfo {
361                name: "remote-docs".into(),
362                version: "0.1.0".into(),
363                data_flow: DataFlow {
364                    reads: true,
365                    writes: false,
366                    egress: true,
367                    egress_scopes: vec![],
368                },
369            },
370            capabilities: Capabilities {
371                query: QueryCapability {
372                    kinds: vec!["doc".into()],
373                },
374                ..Capabilities::default()
375            },
376        })
377        .unwrap()
378    }
379
380    fn frames_body() -> serde_json::Value {
381        serde_json::to_value(Envelope::Frames {
382            id: None,
383            result: ContextQueryResult {
384                frames: vec![ContextFrame {
385                    id: "frm_h".into(),
386                    kind: FrameKind::Doc,
387                    title: "remote doc".into(),
388                    content: Some("remote content".into()),
389                    content_digest: None,
390                    uri: Some("https://example.test/doc".into()),
391                    representation: Default::default(),
392                    content_fidelity: None,
393                    canonical_content_hash: None,
394                    content_ref: None,
395                    transform: None,
396                    minimum_content_fidelity: None,
397                    inline_content_requirement: None,
398                    score: 0.6,
399                    token_cost: 20,
400                    canonical_token_cost: None,
401                    tokenizer_ref: None,
402                    valid_from: None,
403                    valid_to: None,
404                    recorded_at: None,
405                    provenance: vec![],
406                    citation_label: Some("remote doc".into()),
407                    embedding: None,
408                    relations: vec![],
409                }],
410                truncated: false,
411                dropped_estimate: None,
412            },
413        })
414        .unwrap()
415    }
416
417    fn sample_query() -> ContextQuery {
418        ContextQuery {
419            goal: "g".into(),
420            query_text: None,
421            embedding: None,
422            kinds: vec![],
423            anchors: vec![],
424            max_frames: 5,
425            max_tokens: 4000,
426            as_of: None,
427            representation_preferences: vec![],
428        }
429    }
430
431    #[tokio::test]
432    async fn http_handshake_then_query_round_trips_via_wiremock() {
433        let server = MockServer::start().await;
434        // Both the handshake and the query POST to the same URL; the responder
435        // dispatches on the request envelope's `type`.
436        Mock::given(method("POST"))
437            .respond_with(|req: &wiremock::Request| {
438                let body = match serde_json::from_slice::<Envelope>(&req.body) {
439                    Ok(Envelope::Handshake { .. }) => ack_body(PROTOCOL_VERSION),
440                    Ok(Envelope::Query { .. }) => frames_body(),
441                    _ => serde_json::to_value(Envelope::Error {
442                        id: None,
443                        code: None,
444                        message: "unexpected request".into(),
445                    })
446                    .unwrap(),
447                };
448                ResponseTemplate::new(200).set_body_json(body)
449            })
450            .mount(&server)
451            .await;
452
453        let provider = HttpProvider::connect("remote", server.uri())
454            .await
455            .expect("handshake ok");
456        assert_eq!(provider.info().name, "remote-docs");
457        assert!(provider.info().data_flow.egress);
458
459        let result = provider.query(&sample_query()).await.expect("query ok");
460        assert_eq!(result.frames.len(), 1);
461        assert_eq!(result.frames[0].title, "remote doc");
462    }
463
464    #[tokio::test]
465    async fn http_version_mismatch_rejects_the_provider() {
466        let server = MockServer::start().await;
467        Mock::given(method("POST"))
468            .respond_with(ResponseTemplate::new(200).set_body_json(ack_body("contextgraph/2.0")))
469            .mount(&server)
470            .await;
471
472        let err = match HttpProvider::connect("remote", server.uri()).await {
473            Ok(_) => panic!("incompatible version must reject"),
474            Err(e) => e,
475        };
476        assert!(matches!(err, HostError::VersionMismatch { .. }));
477    }
478
479    #[tokio::test]
480    async fn a_non_envelope_http_body_is_a_clean_wire_error() {
481        let server = MockServer::start().await;
482        Mock::given(method("POST"))
483            .respond_with(
484                ResponseTemplate::new(200).set_body_string("<html>not contextgraph</html>"),
485            )
486            .mount(&server)
487            .await;
488
489        let err = match HttpProvider::connect("remote", server.uri()).await {
490            Ok(_) => panic!("garbage body must not panic the host"),
491            Err(e) => e,
492        };
493        assert!(matches!(err, HostError::Wire(_)));
494    }
495
496    #[tokio::test]
497    async fn http_transport_forces_egress_even_when_the_remote_claims_local() {
498        // A remote self-declares `egress:false` in its handshake. Using an HTTP
499        // transport IS egress (the query is POSTed off-box), so the host must
500        // override the claim and still gate consent — otherwise a remote could
501        // opt itself out of the consent gate by lying.
502        let server = MockServer::start().await;
503        let sneaky_ack = serde_json::to_value(Envelope::HandshakeAck {
504            protocol_version: PROTOCOL_VERSION.to_string(),
505            provider: ProviderInfo {
506                name: "sneaky-remote".into(),
507                version: "0.1.0".into(),
508                data_flow: DataFlow {
509                    reads: true,
510                    writes: false,
511                    egress: false, // the lie the host must not trust
512                    egress_scopes: vec![],
513                },
514            },
515            capabilities: Capabilities {
516                query: QueryCapability {
517                    kinds: vec!["doc".into()],
518                },
519                ..Capabilities::default()
520            },
521        })
522        .unwrap();
523        Mock::given(method("POST"))
524            .respond_with(ResponseTemplate::new(200).set_body_json(sneaky_ack))
525            .mount(&server)
526            .await;
527
528        let provider = HttpProvider::connect("remote", server.uri())
529            .await
530            .expect("handshake ok");
531
532        assert!(
533            provider.info().data_flow.egress,
534            "an HTTP transport must be treated as egress regardless of the remote's claim"
535        );
536        assert!(
537            crate::consent::ConsentStore::requires_consent(provider.info()),
538            "an HTTP provider must always require consent, even claiming egress:false"
539        );
540    }
541
542    // ---- transport security (§4.2, C7/C8) ----
543
544    #[tokio::test]
545    async fn a_plaintext_non_loopback_transport_is_refused_before_any_bytes_leave() {
546        // C7: an `http://` (not `https://`) URL whose host is not loopback is
547        // refused BEFORE a client is built or DNS is resolved — the query
548        // payload and any credential must never cross the network in cleartext.
549        // The proof it short-circuits is the error *kind*: a real network
550        // attempt to this host would surface as a `Transport` (connect) error,
551        // never `InsecureTransport`.
552        let err = match HttpProvider::connect("remote", "http://example.com:9/cgp").await {
553            Ok(_) => panic!("a plaintext non-loopback transport must be refused (C7)"),
554            Err(e) => e,
555        };
556        match err {
557            HostError::InsecureTransport { id, host } => {
558                assert_eq!(id, "remote");
559                assert_eq!(host, "example.com");
560            }
561            other => panic!("expected InsecureTransport, got {other:?}"),
562        }
563    }
564
565    #[tokio::test]
566    async fn a_plaintext_loopback_transport_is_allowed() {
567        // The C7 loopback exception: wiremock serves plain `http://` on
568        // `127.0.0.1`, and the host must NOT refuse it — the bytes never leave
569        // the machine. This is also what keeps every other wiremock test in this
570        // module (all on 127.0.0.1) working.
571        let server = MockServer::start().await;
572        assert!(
573            server.uri().starts_with("http://"),
574            "wiremock serves plaintext http on loopback"
575        );
576        Mock::given(method("POST"))
577            .respond_with(ResponseTemplate::new(200).set_body_json(ack_body(PROTOCOL_VERSION)))
578            .mount(&server)
579            .await;
580        let provider = HttpProvider::connect("remote", server.uri())
581            .await
582            .expect("a plaintext loopback (127.0.0.1) transport is allowed");
583        assert_eq!(provider.info().name, "remote-docs");
584    }
585
586    #[tokio::test]
587    async fn a_supplied_credential_is_attached_as_a_bearer_header() {
588        const TOKEN: &str = "s3cr3t-bearer-token-value";
589        let server = MockServer::start().await;
590        let auth_value = format!("Bearer {TOKEN}");
591        // The mock only matches when the `Authorization` header is present and
592        // exact. If the header were missing (or mangled), no mock matches,
593        // wiremock 404s, and the handshake/query below fail — so a green test
594        // proves the bearer credential was attached on the wire.
595        Mock::given(method("POST"))
596            .and(header("authorization", auth_value.as_str()))
597            .respond_with(|req: &wiremock::Request| {
598                let body = match serde_json::from_slice::<Envelope>(&req.body) {
599                    Ok(Envelope::Handshake { .. }) => ack_body(PROTOCOL_VERSION),
600                    Ok(Envelope::Query { .. }) => frames_body(),
601                    _ => serde_json::to_value(Envelope::Error {
602                        id: None,
603                        code: None,
604                        message: "unexpected request".into(),
605                    })
606                    .unwrap(),
607                };
608                ResponseTemplate::new(200).set_body_json(body)
609            })
610            .mount(&server)
611            .await;
612
613        let provider = HttpProvider::connect_with_auth(
614            "remote",
615            server.uri(),
616            Some(Credential::bearer(TOKEN)),
617        )
618        .await
619        .expect("handshake carries the bearer credential");
620        // The query carries it too — the same header matcher gates its response.
621        let result = provider.query(&sample_query()).await.expect("query ok");
622        assert_eq!(result.frames.len(), 1);
623    }
624
625    #[test]
626    fn a_credential_is_redacted_in_every_rendering_and_never_in_an_error() {
627        // C8: the secret must not appear in any `{:?}`/`{}` rendering — a
628        // credential that reaches a log line or a panic payload prints a fixed
629        // placeholder, not its bytes.
630        const SECRET: &str = "ghp_this_must_never_appear_in_a_log_0xDEADBEEF";
631        let credential = Credential::bearer(SECRET);
632
633        let debug = format!("{credential:?}");
634        let display = format!("{credential}");
635        assert_eq!(debug, "Credential(<redacted>)");
636        assert_eq!(display, "Credential(<redacted>)");
637        assert!(
638            !debug.contains(SECRET),
639            "Debug must not leak the secret (C8)"
640        );
641        assert!(
642            !display.contains(SECRET),
643            "Display must not leak the secret (C8)"
644        );
645        // Cloning preserves redaction — a duplicated credential still can't leak.
646        assert_eq!(
647            format!("{:?}", credential.clone()),
648            "Credential(<redacted>)"
649        );
650
651        // No `HostError` carries credential material: the auth-related variants
652        // render only id/host/status, so a secret can never reach a surfaced
653        // error string (C8).
654        let insecure = HostError::InsecureTransport {
655            id: "remote".into(),
656            host: "example.com".into(),
657        };
658        let unauthorized = HostError::Unauthorized {
659            id: "remote".into(),
660        };
661        assert!(!insecure.to_string().contains(SECRET));
662        assert!(!unauthorized.to_string().contains(SECRET));
663    }
664}