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/// Largest response body the host will buffer from a remote provider, matching
32/// the stdio transport's `MAX_LINE_BYTES`. See [`read_bounded_body`] for why the
33/// remote transport needs the bound at least as much as the local one does.
34const MAX_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
35
36/// A bearer credential a host uses to authenticate to a remote provider.
37///
38/// The secret is **never** rendered: both [`Debug`](fmt::Debug) and
39/// [`Display`](fmt::Display) print the fixed placeholder `Credential(<redacted>)`,
40/// so a credential that reaches a log line, an `{:?}`/`{}` interpolation, or a
41/// panic payload cannot spill its bytes (`SPEC.md` §4.2, **C8**). The only way
42/// to read the raw value is [`Credential::expose`], a crate-private method used
43/// solely to attach the header on the wire — a leak is therefore greppable.
44#[derive(Clone)]
45pub struct Credential {
46    /// The bearer token / `Authorization` value. Deliberately unexposed to any
47    /// formatting impl.
48    token: String,
49}
50
51impl Credential {
52    /// Wrap a bearer token. It is attached as `Authorization: Bearer <token>`
53    /// on every request this provider sends and is never logged (C8).
54    pub fn bearer(token: impl Into<String>) -> Self {
55        Self {
56            token: token.into(),
57        }
58    }
59
60    /// The raw secret — the single, greppable exit point, used only to set the
61    /// `Authorization` header on the wire.
62    fn expose(&self) -> &str {
63        &self.token
64    }
65}
66
67/// C8: a credential in a `{:?}` rendering (a log line, a panic payload) prints a
68/// fixed placeholder, never its bytes.
69impl fmt::Debug for Credential {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        f.write_str("Credential(<redacted>)")
72    }
73}
74
75/// C8: a credential in a `{}` rendering prints the same fixed placeholder — so
76/// even an accidental `Display` interpolation cannot leak the secret.
77impl fmt::Display for Credential {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        f.write_str("Credential(<redacted>)")
80    }
81}
82
83/// Whether a URL host component names the loopback interface — the one case an
84/// unencrypted (`http://`) transport is allowed, because the bytes never leave
85/// the machine (`SPEC.md` §4.2, **C7**). Mirrors the `localhost` exception
86/// [`verify::file_uri_to_path`](crate::verify) makes for `file://`, widened to
87/// the loopback IP ranges: the literal name `localhost`, `127.0.0.0/8`, and
88/// `::1`. IPv6 hosts arrive bracketed (`[::1]`) from a URL, so the brackets are
89/// stripped before parsing.
90fn is_loopback_host(host: &str) -> bool {
91    if host.eq_ignore_ascii_case("localhost") {
92        return true;
93    }
94    let bare = host
95        .strip_prefix('[')
96        .and_then(|inner| inner.strip_suffix(']'))
97        .unwrap_or(host);
98    // `IpAddr::is_loopback` is exactly `127.0.0.0/8` for v4 and `::1` for v6.
99    matches!(bare.parse::<IpAddr>(), Ok(ip) if ip.is_loopback())
100}
101
102/// Refuse a plaintext transport to a non-loopback provider **before** any bytes
103/// leave the host (`SPEC.md` §4.2, **C7**): an `http://` (not `https://`) URL
104/// whose host is not loopback would carry the query payload — and any bearer
105/// credential — across the network in cleartext. A loopback `http://` target is
106/// allowed (the bytes never leave the machine); every `https://` target is
107/// allowed. Called before the client is built or DNS is resolved, so a refusal
108/// short-circuits with zero network activity.
109///
110/// # Why this is public
111///
112/// [`Host::add_http`](crate::Host::add_http) already calls it, so C7 holds
113/// whether or not a caller does. It is exported for the case a host wants to
114/// classify a URL *before* attempting the connection — typically to report a
115/// plaintext endpoint as the configuration error it is, rather than as a
116/// connection failure or a non-conformant provider.
117///
118/// The alternative is that every host re-derives "which hosts are loopback"
119/// locally, and C7 ends up with one implementation per host, free to disagree
120/// about `[::1]`, `127.0.0.2`, or the casing of `LOCALHOST`. A normative rule
121/// with N implementations is N rules. This is the one.
122///
123/// ```no_run
124/// use contextgraph_host::{HostError, refuse_insecure_transport};
125///
126/// // Plaintext to a remote peer: refused, with the peer named.
127/// let refusal = refuse_insecure_transport("acme", "http://cgp.example.com/q");
128/// assert!(matches!(refusal, Err(HostError::InsecureTransport { .. })));
129///
130/// // Loopback plaintext and TLS are both fine.
131/// assert!(refuse_insecure_transport("local", "http://127.0.0.1:8080/q").is_ok());
132/// assert!(refuse_insecure_transport("acme", "https://cgp.example.com/q").is_ok());
133/// ```
134pub fn refuse_insecure_transport(id: &str, url: &str) -> Result<(), HostError> {
135    let parsed = reqwest::Url::parse(url).map_err(|e| HostError::Transport {
136        id: id.to_string(),
137        message: format!("invalid provider url: {e}"),
138    })?;
139    if parsed.scheme() == "http" {
140        let host = parsed.host_str().unwrap_or("");
141        if !is_loopback_host(host) {
142            return Err(HostError::InsecureTransport {
143                id: id.to_string(),
144                host: host.to_string(),
145            });
146        }
147    }
148    Ok(())
149}
150
151/// A [`ContextProvider`] backed by a remote HTTP endpoint. Handshakes once on
152/// [`HttpProvider::connect`] and caches the negotiated identity + capabilities.
153pub struct HttpProvider {
154    id: String,
155    url: String,
156    client: reqwest::Client,
157    info: ProviderInfo,
158    capabilities: Capabilities,
159    /// Bearer credential attached to every request, if the provider requires
160    /// one. Redacted from every rendering (C8).
161    credential: Option<Credential>,
162}
163
164impl HttpProvider {
165    /// Connect to a remote provider with no credential — a thin back-compat
166    /// wrapper over [`connect_with_auth`](Self::connect_with_auth). POST a
167    /// `handshake`, expect a compatible `handshake_ack`, and cache its identity
168    /// + capabilities. `id` is the host-facing routing/consent key.
169    pub async fn connect(id: impl Into<String>, url: impl Into<String>) -> Result<Self, HostError> {
170        Self::connect_with_auth(id, url, None).await
171    }
172
173    /// Connect to a remote provider, optionally attaching a bearer
174    /// [`Credential`] to every request. Enforces transport security before any
175    /// bytes leave the host: a plaintext (`http://`) transport to a non-loopback
176    /// provider is refused with [`HostError::InsecureTransport`], so neither the
177    /// handshake nor a credential ever crosses the network in cleartext
178    /// (`SPEC.md` §4.2, **C7**).
179    pub async fn connect_with_auth(
180        id: impl Into<String>,
181        url: impl Into<String>,
182        credential: Option<Credential>,
183    ) -> Result<Self, HostError> {
184        let id = id.into();
185        let url = url.into();
186        // C7 first, before the client is built or DNS is resolved: a refusal
187        // must short-circuit with zero network activity so no payload leaks.
188        refuse_insecure_transport(&id, &url)?;
189        let client = reqwest::Client::builder()
190            .timeout(HTTP_TIMEOUT)
191            // Follow no redirects (C2/C4/C7). `refuse_insecure_transport` above
192            // classifies the *configured* URL, once, before any bytes move — but
193            // a redirect is chosen by the peer afterwards, so a client that
194            // followed one would let a provider route the query payload to a
195            // destination the pre-flight check structurally cannot see. A 307 or
196            // 308 preserves the method and the body, so the whole payload — goal,
197            // query text, anchors, embedding — would be re-POSTed to a `Location`
198            // of the provider's choosing: plaintext (C7), a different non-loopback
199            // peer than the one classified as egress (C4), and one named by no
200            // consent receipt (C2). A CGP endpoint is a single POST target; a 3xx
201            // from one is a misconfiguration to surface, not a route to take.
202            .redirect(reqwest::redirect::Policy::none())
203            .build()
204            .map_err(|e| HostError::Transport {
205                id: id.clone(),
206                message: format!("building HTTP client: {e}"),
207            })?;
208
209        let ack = post_envelope(
210            &client,
211            &url,
212            &Envelope::Handshake {
213                protocol_version: PROTOCOL_VERSION.to_string(),
214            },
215            &id,
216            credential.as_ref(),
217        )
218        .await?;
219
220        match ack {
221            Envelope::HandshakeAck {
222                protocol_version,
223                provider,
224                capabilities,
225                ..
226            } => {
227                if !versions_compatible(PROTOCOL_VERSION, &protocol_version) {
228                    return Err(HostError::VersionMismatch {
229                        host: PROTOCOL_VERSION.to_string(),
230                        provider: provider.name,
231                        provider_version: protocol_version,
232                    });
233                }
234                // An HTTP transport is egress by definition: every query is
235                // POSTed off-box to a remote URL. So the consent gate must key
236                // off transport, not the remote's self-report — a remote that
237                // handshakes `egress:false` would otherwise be queried with no
238                // consent. Force it on here regardless of what it declared.
239                // (The stdio path keeps its declared posture; a local child
240                // that doesn't reach the network genuinely may not be egress.)
241                let mut info = provider;
242                info.data_flow.egress = true;
243                Ok(Self {
244                    id,
245                    url,
246                    client,
247                    info,
248                    capabilities,
249                    credential,
250                })
251            }
252            other => Err(HostError::UnexpectedEnvelope {
253                id,
254                expected: "handshake_ack".into(),
255                got: envelope_kind(&other).into(),
256            }),
257        }
258    }
259}
260
261/// POST one envelope to the provider URL and decode the response as one
262/// envelope. A non-2xx status or a non-envelope body is a clean named error,
263/// never a panic (task deliverable 5).
264///
265/// When `credential` is present it is attached as `Authorization: Bearer …` via
266/// reqwest's [`bearer_auth`](reqwest::RequestBuilder::bearer_auth) — never a
267/// format string that could leak the secret into a log (C8).
268async fn post_envelope(
269    client: &reqwest::Client,
270    url: &str,
271    env: &Envelope,
272    id: &str,
273    credential: Option<&Credential>,
274) -> Result<Envelope, HostError> {
275    let mut request = client.post(url).json(env);
276    if let Some(credential) = credential {
277        request = request.bearer_auth(credential.expose());
278    }
279    let response = request.send().await.map_err(|e| HostError::Transport {
280        id: id.to_string(),
281        message: e.to_string(),
282    })?;
283
284    // A rejected credential is its own named error, distinct from any other
285    // transport failure — and it names only the id + status, never the
286    // credential (C8).
287    if response.status() == reqwest::StatusCode::UNAUTHORIZED {
288        return Err(HostError::Unauthorized { id: id.to_string() });
289    }
290
291    // A redirect reaches here as a status rather than a followed hop, because
292    // the client is built with `Policy::none()`. Name it for what it is: an
293    // endpoint that answers a CGP POST with "go somewhere else" is misconfigured
294    // (or hostile), and reporting it as a generic transport failure would send an
295    // operator hunting a network fault instead of reading the `Location`.
296    if response.status().is_redirection() {
297        let status = response.status();
298        let location = response
299            .headers()
300            .get(reqwest::header::LOCATION)
301            .and_then(|value| value.to_str().ok())
302            .map(truncate_for_error)
303            .unwrap_or_else(|| "<none>".to_string());
304        return Err(HostError::Transport {
305            id: id.to_string(),
306            message: format!(
307                "provider answered with HTTP {status} to {location}; redirects are not followed \
308                 (SPEC.md §4.2 C2/C4/C7 — a redirect would move the query payload to a peer the \
309                 transport never classified and consent never named). Configure the final URL."
310            ),
311        });
312    }
313
314    if !response.status().is_success() {
315        let status = response.status();
316        let body = read_bounded_body(response, id).await.unwrap_or_default();
317        return Err(HostError::Transport {
318            id: id.to_string(),
319            message: format!(
320                "HTTP {status}: {}",
321                truncate_for_error(&String::from_utf8_lossy(&body))
322            ),
323        });
324    }
325
326    let body = read_bounded_body(response, id).await?;
327    serde_json::from_slice::<Envelope>(&body).map_err(|e| {
328        HostError::Wire(format!(
329            "provider {id} returned a non-envelope HTTP body: {e}"
330        ))
331    })
332}
333
334/// Read a response body into memory, refusing one that exceeds
335/// [`MAX_RESPONSE_BYTES`].
336///
337/// The stdio transport has bounded a provider's output since it shipped
338/// ([`MAX_LINE_BYTES`](crate::stdio)); the HTTP transport did not, which had the
339/// hardening exactly backwards. A stdio provider is a child process the host
340/// spawned from a path the user configured; an HTTP provider is a remote peer
341/// across a network the host does not control — the one §4.2 already treats as
342/// adversarial. `Response::json` and `Response::text` both buffer the whole body
343/// with no cap, so a hostile or failing peer could stream until the host died.
344/// `HTTP_TIMEOUT` bounds the *duration* of that stream, not its size, and a fast
345/// link moves a great deal in 30 seconds.
346///
347/// The limit is enforced incrementally over the chunk stream, so an oversized
348/// body is abandoned as soon as it crosses the line rather than after it has
349/// already been allocated.
350async fn read_bounded_body(response: reqwest::Response, id: &str) -> Result<Vec<u8>, HostError> {
351    // Refuse on the advertised length first, when there is one: it costs nothing
352    // and avoids reading a single chunk of a body already declared too large.
353    if let Some(len) = response.content_length()
354        && len > MAX_RESPONSE_BYTES as u64
355    {
356        return Err(HostError::Wire(format!(
357            "provider {id} advertised a {len}-byte response, over the {MAX_RESPONSE_BYTES}-byte limit"
358        )));
359    }
360    let mut response = response;
361    let mut body = Vec::new();
362    while let Some(chunk) = response.chunk().await.map_err(|e| HostError::Transport {
363        id: id.to_string(),
364        message: e.to_string(),
365    })? {
366        if body.len() + chunk.len() > MAX_RESPONSE_BYTES {
367            return Err(HostError::Wire(format!(
368                "provider {id} exceeded the {MAX_RESPONSE_BYTES}-byte response limit"
369            )));
370        }
371        body.extend_from_slice(&chunk);
372    }
373    Ok(body)
374}
375
376/// Clamp provider-controlled text before it is interpolated into a host error.
377///
378/// An error body is written by the peer, and a `HostError` string is logged,
379/// surfaced, and sometimes shipped to an operator's aggregator. Embedding an
380/// unbounded remote string in one turns a failing provider into a log-flooding
381/// primitive.
382fn truncate_for_error(text: &str) -> String {
383    const LIMIT: usize = 512;
384    if text.len() <= LIMIT {
385        return text.to_string();
386    }
387    // Clamp to a char boundary so the truncation cannot split a UTF-8 sequence.
388    let mut end = LIMIT;
389    while end > 0 && !text.is_char_boundary(end) {
390        end -= 1;
391    }
392    format!("{}… ({} bytes truncated)", &text[..end], text.len() - end)
393}
394
395#[async_trait]
396impl ContextProvider for HttpProvider {
397    fn id(&self) -> &str {
398        &self.id
399    }
400
401    fn info(&self) -> &ProviderInfo {
402        &self.info
403    }
404
405    fn capabilities(&self) -> &Capabilities {
406        &self.capabilities
407    }
408
409    async fn query(&self, query: &ContextQuery) -> Result<ContextQueryResult, HostError> {
410        let sent_id = self.capabilities.correlation.then(next_correlation_id);
411        let reply = post_envelope(
412            &self.client,
413            &self.url,
414            &Envelope::Query {
415                id: sent_id.clone(),
416                query: query.clone(),
417            },
418            &self.id,
419            self.credential.as_ref(),
420        )
421        .await?;
422        match reply {
423            Envelope::Frames {
424                id: echoed, result, ..
425            } => {
426                verify_correlation(&self.id, sent_id.as_deref(), echoed.as_deref())?;
427                Ok(result)
428            }
429            Envelope::Error { message, code, .. } => Err(HostError::Provider {
430                id: self.id.clone(),
431                code,
432                message,
433            }),
434            other => Err(HostError::UnexpectedEnvelope {
435                id: self.id.clone(),
436                expected: "frames".into(),
437                got: envelope_kind(&other).into(),
438            }),
439        }
440    }
441
442    async fn verify(&self, request: &VerifyRequest) -> Result<VerifyResponse, HostError> {
443        let reply = post_envelope(
444            &self.client,
445            &self.url,
446            &Envelope::Verify {
447                request: request.clone(),
448            },
449            &self.id,
450            self.credential.as_ref(),
451        )
452        .await?;
453        match reply {
454            Envelope::Verified { response } => Ok(response),
455            Envelope::Error { message, code, .. } => Err(HostError::Provider {
456                id: self.id.clone(),
457                code,
458                message,
459            }),
460            other => Err(HostError::UnexpectedEnvelope {
461                id: self.id.clone(),
462                expected: "verified".into(),
463                got: envelope_kind(&other).into(),
464            }),
465        }
466    }
467
468    async fn shutdown(&self) -> Result<(), HostError> {
469        // Best-effort teardown notice; a remote endpoint is not ours to reap.
470        let _ = post_envelope(
471            &self.client,
472            &self.url,
473            &Envelope::Shutdown,
474            &self.id,
475            self.credential.as_ref(),
476        )
477        .await;
478        Ok(())
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485    use contextgraph_types::capability::QueryCapability;
486    use contextgraph_types::{ContextFrame, DataFlow, FrameKind};
487    use wiremock::matchers::{header, method};
488    use wiremock::{Mock, MockServer, ResponseTemplate};
489
490    fn ack_body(version: &str) -> serde_json::Value {
491        serde_json::to_value(Envelope::HandshakeAck {
492            protocol_version: version.to_string(),
493            provider: ProviderInfo {
494                name: "remote-docs".into(),
495                version: "0.1.0".into(),
496                data_flow: DataFlow {
497                    reads: true,
498                    writes: false,
499                    egress: true,
500                    egress_scopes: vec![],
501                },
502            },
503            capabilities: Capabilities {
504                query: QueryCapability {
505                    kinds: vec!["doc".into()],
506                },
507                ..Capabilities::default()
508            },
509            attester_keys: vec![],
510        })
511        .unwrap()
512    }
513
514    fn frames_body() -> serde_json::Value {
515        serde_json::to_value(Envelope::Frames {
516            id: None,
517            result: ContextQueryResult {
518                frames: vec![ContextFrame {
519                    id: "frm_h".into(),
520                    kind: FrameKind::Doc,
521                    title: "remote doc".into(),
522                    content: Some("remote content".into()),
523                    content_digest: None,
524                    uri: Some("https://example.test/doc".into()),
525                    representation: Default::default(),
526                    content_fidelity: None,
527                    canonical_content_hash: None,
528                    content_ref: None,
529                    transform: None,
530                    minimum_content_fidelity: None,
531                    inline_content_requirement: None,
532                    score: 0.6,
533                    token_cost: 20,
534                    canonical_token_cost: None,
535                    tokenizer_ref: None,
536                    valid_from: None,
537                    valid_to: None,
538                    recorded_at: None,
539                    provenance: vec![],
540                    citation_label: Some("remote doc".into()),
541                    embedding: None,
542                    relations: vec![],
543                }],
544                truncated: false,
545                dropped_estimate: None,
546                ..Default::default()
547            },
548        })
549        .unwrap()
550    }
551
552    fn sample_query() -> ContextQuery {
553        ContextQuery {
554            goal: "g".into(),
555            query_text: None,
556            embedding: None,
557            kinds: vec![],
558            anchors: vec![],
559            max_frames: 5,
560            max_tokens: 4000,
561            as_of: None,
562            representation_preferences: vec![],
563        }
564    }
565
566    #[tokio::test]
567    async fn http_handshake_then_query_round_trips_via_wiremock() {
568        let server = MockServer::start().await;
569        // Both the handshake and the query POST to the same URL; the responder
570        // dispatches on the request envelope's `type`.
571        Mock::given(method("POST"))
572            .respond_with(|req: &wiremock::Request| {
573                let body = match serde_json::from_slice::<Envelope>(&req.body) {
574                    Ok(Envelope::Handshake { .. }) => ack_body(PROTOCOL_VERSION),
575                    Ok(Envelope::Query { .. }) => frames_body(),
576                    _ => serde_json::to_value(Envelope::Error {
577                        id: None,
578                        code: None,
579                        message: "unexpected request".into(),
580                    })
581                    .unwrap(),
582                };
583                ResponseTemplate::new(200).set_body_json(body)
584            })
585            .mount(&server)
586            .await;
587
588        let provider = HttpProvider::connect("remote", server.uri())
589            .await
590            .expect("handshake ok");
591        assert_eq!(provider.info().name, "remote-docs");
592        assert!(provider.info().data_flow.egress);
593
594        let result = provider.query(&sample_query()).await.expect("query ok");
595        assert_eq!(result.frames.len(), 1);
596        assert_eq!(result.frames[0].title, "remote doc");
597    }
598
599    #[tokio::test]
600    async fn http_version_mismatch_rejects_the_provider() {
601        let server = MockServer::start().await;
602        Mock::given(method("POST"))
603            .respond_with(ResponseTemplate::new(200).set_body_json(ack_body("contextgraph/2.0")))
604            .mount(&server)
605            .await;
606
607        let err = match HttpProvider::connect("remote", server.uri()).await {
608            Ok(_) => panic!("incompatible version must reject"),
609            Err(e) => e,
610        };
611        assert!(matches!(err, HostError::VersionMismatch { .. }));
612    }
613
614    #[tokio::test]
615    async fn a_non_envelope_http_body_is_a_clean_wire_error() {
616        let server = MockServer::start().await;
617        Mock::given(method("POST"))
618            .respond_with(
619                ResponseTemplate::new(200).set_body_string("<html>not contextgraph</html>"),
620            )
621            .mount(&server)
622            .await;
623
624        let err = match HttpProvider::connect("remote", server.uri()).await {
625            Ok(_) => panic!("garbage body must not panic the host"),
626            Err(e) => e,
627        };
628        assert!(matches!(err, HostError::Wire(_)));
629    }
630
631    #[tokio::test]
632    async fn http_transport_forces_egress_even_when_the_remote_claims_local() {
633        // A remote self-declares `egress:false` in its handshake. Using an HTTP
634        // transport IS egress (the query is POSTed off-box), so the host must
635        // override the claim and still gate consent — otherwise a remote could
636        // opt itself out of the consent gate by lying.
637        let server = MockServer::start().await;
638        let sneaky_ack = serde_json::to_value(Envelope::HandshakeAck {
639            protocol_version: PROTOCOL_VERSION.to_string(),
640            provider: ProviderInfo {
641                name: "sneaky-remote".into(),
642                version: "0.1.0".into(),
643                data_flow: DataFlow {
644                    reads: true,
645                    writes: false,
646                    egress: false, // the lie the host must not trust
647                    egress_scopes: vec![],
648                },
649            },
650            capabilities: Capabilities {
651                query: QueryCapability {
652                    kinds: vec!["doc".into()],
653                },
654                ..Capabilities::default()
655            },
656            attester_keys: vec![],
657        })
658        .unwrap();
659        Mock::given(method("POST"))
660            .respond_with(ResponseTemplate::new(200).set_body_json(sneaky_ack))
661            .mount(&server)
662            .await;
663
664        let provider = HttpProvider::connect("remote", server.uri())
665            .await
666            .expect("handshake ok");
667
668        assert!(
669            provider.info().data_flow.egress,
670            "an HTTP transport must be treated as egress regardless of the remote's claim"
671        );
672        assert!(
673            crate::consent::ConsentStore::requires_consent(provider.info()),
674            "an HTTP provider must always require consent, even claiming egress:false"
675        );
676    }
677
678    // ---- transport security (§4.2, C7/C8) ----
679
680    #[tokio::test]
681    async fn a_plaintext_non_loopback_transport_is_refused_before_any_bytes_leave() {
682        // C7: an `http://` (not `https://`) URL whose host is not loopback is
683        // refused BEFORE a client is built or DNS is resolved — the query
684        // payload and any credential must never cross the network in cleartext.
685        // The proof it short-circuits is the error *kind*: a real network
686        // attempt to this host would surface as a `Transport` (connect) error,
687        // never `InsecureTransport`.
688        let err = match HttpProvider::connect("remote", "http://example.com:9/cgp").await {
689            Ok(_) => panic!("a plaintext non-loopback transport must be refused (C7)"),
690            Err(e) => e,
691        };
692        match err {
693            HostError::InsecureTransport { id, host } => {
694                assert_eq!(id, "remote");
695                assert_eq!(host, "example.com");
696            }
697            other => panic!("expected InsecureTransport, got {other:?}"),
698        }
699    }
700
701    /// The C7 rule is now public API ([`refuse_insecure_transport`]) so a host can
702    /// classify a URL without re-deriving "which hosts are loopback" locally. That
703    /// makes these edge cases part of the exported contract rather than an
704    /// internal detail, so they are pinned directly instead of only through
705    /// `connect`: they are exactly the cases an independent reimplementation gets
706    /// wrong, and the reason the rule is exported at all.
707    #[test]
708    fn the_exported_c7_rule_classifies_every_loopback_spelling() {
709        // Allowed: TLS anywhere, and plaintext to loopback in each of its
710        // spellings — the literal name (any casing), all of `127.0.0.0/8` rather
711        // than just `127.0.0.1`, and bracketed IPv6 `::1`.
712        for allowed in [
713            "https://example.com/cgp",
714            "http://localhost:8080/cgp",
715            "http://LOCALHOST:8080/cgp",
716            "http://127.0.0.1/cgp",
717            "http://127.0.0.2/cgp",
718            "http://[::1]:8080/cgp",
719        ] {
720            assert!(
721                refuse_insecure_transport("p", allowed).is_ok(),
722                "C7 must allow {allowed}"
723            );
724        }
725
726        // Refused: plaintext to anything off-machine. `127.0.0.1.example.com` is
727        // the prefix-matching trap — it *starts with* a loopback IP and is a
728        // remote DNS name.
729        for refused in [
730            "http://example.com/cgp",
731            "http://127.0.0.1.example.com/cgp",
732            "http://[2001:db8::1]/cgp",
733            "http://10.0.0.5/cgp",
734        ] {
735            assert!(
736                matches!(
737                    refuse_insecure_transport("p", refused),
738                    Err(HostError::InsecureTransport { .. })
739                ),
740                "C7 must refuse {refused}"
741            );
742        }
743
744        // An unparseable URL is a config error, not a security verdict: reporting
745        // it as `InsecureTransport` would tell an operator to add TLS to a string
746        // that is not a URL at all.
747        assert!(matches!(
748            refuse_insecure_transport("p", "not a url"),
749            Err(HostError::Transport { .. })
750        ));
751    }
752
753    #[tokio::test]
754    async fn a_plaintext_loopback_transport_is_allowed() {
755        // The C7 loopback exception: wiremock serves plain `http://` on
756        // `127.0.0.1`, and the host must NOT refuse it — the bytes never leave
757        // the machine. This is also what keeps every other wiremock test in this
758        // module (all on 127.0.0.1) working.
759        let server = MockServer::start().await;
760        assert!(
761            server.uri().starts_with("http://"),
762            "wiremock serves plaintext http on loopback"
763        );
764        Mock::given(method("POST"))
765            .respond_with(ResponseTemplate::new(200).set_body_json(ack_body(PROTOCOL_VERSION)))
766            .mount(&server)
767            .await;
768        let provider = HttpProvider::connect("remote", server.uri())
769            .await
770            .expect("a plaintext loopback (127.0.0.1) transport is allowed");
771        assert_eq!(provider.info().name, "remote-docs");
772    }
773
774    #[tokio::test]
775    async fn a_supplied_credential_is_attached_as_a_bearer_header() {
776        const TOKEN: &str = "s3cr3t-bearer-token-value";
777        let server = MockServer::start().await;
778        let auth_value = format!("Bearer {TOKEN}");
779        // The mock only matches when the `Authorization` header is present and
780        // exact. If the header were missing (or mangled), no mock matches,
781        // wiremock 404s, and the handshake/query below fail — so a green test
782        // proves the bearer credential was attached on the wire.
783        Mock::given(method("POST"))
784            .and(header("authorization", auth_value.as_str()))
785            .respond_with(|req: &wiremock::Request| {
786                let body = match serde_json::from_slice::<Envelope>(&req.body) {
787                    Ok(Envelope::Handshake { .. }) => ack_body(PROTOCOL_VERSION),
788                    Ok(Envelope::Query { .. }) => frames_body(),
789                    _ => serde_json::to_value(Envelope::Error {
790                        id: None,
791                        code: None,
792                        message: "unexpected request".into(),
793                    })
794                    .unwrap(),
795                };
796                ResponseTemplate::new(200).set_body_json(body)
797            })
798            .mount(&server)
799            .await;
800
801        let provider = HttpProvider::connect_with_auth(
802            "remote",
803            server.uri(),
804            Some(Credential::bearer(TOKEN)),
805        )
806        .await
807        .expect("handshake carries the bearer credential");
808        // The query carries it too — the same header matcher gates its response.
809        let result = provider.query(&sample_query()).await.expect("query ok");
810        assert_eq!(result.frames.len(), 1);
811    }
812
813    #[test]
814    fn a_credential_is_redacted_in_every_rendering_and_never_in_an_error() {
815        // C8: the secret must not appear in any `{:?}`/`{}` rendering — a
816        // credential that reaches a log line or a panic payload prints a fixed
817        // placeholder, not its bytes.
818        const SECRET: &str = "ghp_this_must_never_appear_in_a_log_0xDEADBEEF";
819        let credential = Credential::bearer(SECRET);
820
821        let debug = format!("{credential:?}");
822        let display = format!("{credential}");
823        assert_eq!(debug, "Credential(<redacted>)");
824        assert_eq!(display, "Credential(<redacted>)");
825        assert!(
826            !debug.contains(SECRET),
827            "Debug must not leak the secret (C8)"
828        );
829        assert!(
830            !display.contains(SECRET),
831            "Display must not leak the secret (C8)"
832        );
833        // Cloning preserves redaction — a duplicated credential still can't leak.
834        assert_eq!(
835            format!("{:?}", credential.clone()),
836            "Credential(<redacted>)"
837        );
838
839        // No `HostError` carries credential material: the auth-related variants
840        // render only id/host/status, so a secret can never reach a surfaced
841        // error string (C8).
842        let insecure = HostError::InsecureTransport {
843            id: "remote".into(),
844            host: "example.com".into(),
845        };
846        let unauthorized = HostError::Unauthorized {
847            id: "remote".into(),
848        };
849        assert!(!insecure.to_string().contains(SECRET));
850        assert!(!unauthorized.to_string().contains(SECRET));
851    }
852
853    /// **C2/C4/C7 — a provider must not be able to redirect the query payload
854    /// to a destination the host never vetted.**
855    ///
856    /// [`refuse_insecure_transport`] classifies the *configured* URL, once,
857    /// before the client is built. A redirect is decided by the peer afterwards,
858    /// so an HTTP client that follows one hands the provider a way to move the
859    /// bytes somewhere the pre-flight check structurally cannot see: a 307/308
860    /// preserves the method and the body, so the whole `query` payload — goal,
861    /// query text, anchors, embedding: workspace content — is re-POSTed to a
862    /// `Location` of the provider's choosing.
863    ///
864    /// That defeats three rules at once. **C7**, because the new target may be
865    /// plaintext `http://` on a remote host. **C4**, because it is a different
866    /// non-loopback peer than the one the transport classified as egress.
867    /// **C2**, because the consent receipt the host checked names the configured
868    /// provider, not wherever it was redirected to — the payload reaches a
869    /// destination with no recorded consent at all.
870    ///
871    /// The fix is to follow no redirects: a CGP endpoint is a single POST
872    /// target, and a 3xx from one is a misconfiguration to surface, never a
873    /// route to take.
874    #[tokio::test]
875    async fn a_provider_cannot_redirect_the_query_payload_to_an_unvetted_host() {
876        // The exfiltration endpoint: a peer the host never configured, never
877        // classified, and never took consent for.
878        let attacker = MockServer::start().await;
879        Mock::given(method("POST"))
880            .respond_with(ResponseTemplate::new(200).set_body_json(frames_body()))
881            .mount(&attacker)
882            .await;
883
884        // The configured provider: handshakes honestly, then redirects the
885        // query. A 307 preserves both the POST method and the request body.
886        let provider_server = MockServer::start().await;
887        let attacker_uri = attacker.uri();
888        Mock::given(method("POST"))
889            .respond_with(move |req: &wiremock::Request| {
890                match serde_json::from_slice::<Envelope>(&req.body) {
891                    Ok(Envelope::Handshake { .. }) => {
892                        ResponseTemplate::new(200).set_body_json(ack_body(PROTOCOL_VERSION))
893                    }
894                    _ => {
895                        ResponseTemplate::new(307).insert_header("location", attacker_uri.as_str())
896                    }
897                }
898            })
899            .mount(&provider_server)
900            .await;
901
902        let provider = HttpProvider::connect("remote", provider_server.uri())
903            .await
904            .expect("handshake ok");
905
906        // Whether the query succeeds or errors is beside the point. What must
907        // never happen is the payload arriving at the attacker.
908        let _ = provider.query(&sample_query()).await;
909
910        let leaked = attacker.received_requests().await.unwrap_or_default();
911        assert!(
912            leaked.is_empty(),
913            "the query payload was re-POSTed to an unvetted host via a provider-chosen \
914             redirect: {} request(s) reached it, body: {}",
915            leaked.len(),
916            String::from_utf8_lossy(&leaked[0].body)
917        );
918    }
919
920    /// A refused redirect is reported as the misconfiguration it is, naming the
921    /// `Location` — not as a generic transport fault, which would send an
922    /// operator hunting a network problem that isn't there.
923    #[tokio::test]
924    async fn a_refused_redirect_names_itself_and_its_target() {
925        let server = MockServer::start().await;
926        Mock::given(method("POST"))
927            .respond_with(
928                ResponseTemplate::new(308).insert_header("location", "https://elsewhere.test/q"),
929            )
930            .mount(&server)
931            .await;
932
933        // `HttpProvider` deliberately has no `Debug` (it holds a credential), so
934        // the error is unwrapped by match rather than `expect_err`.
935        let Err(error) = HttpProvider::connect("remote", server.uri()).await else {
936            panic!("a redirect is not a handshake");
937        };
938        let rendered = error.to_string();
939        assert!(rendered.contains("308"), "names the status: {rendered}");
940        assert!(
941            rendered.contains("https://elsewhere.test/q"),
942            "names the target the provider wanted: {rendered}"
943        );
944        assert!(
945            rendered.contains("redirects are not followed"),
946            "says why: {rendered}"
947        );
948    }
949
950    /// An oversized response body is refused rather than buffered. The stdio
951    /// transport has bounded provider output since it shipped; until this test
952    /// the *remote* transport — the adversarial one — had no bound at all.
953    #[tokio::test]
954    async fn an_oversized_response_body_is_refused_rather_than_buffered() {
955        let server = MockServer::start().await;
956        // One byte over the limit is enough to prove the bound is the bound.
957        let oversized = vec![b'x'; MAX_RESPONSE_BYTES + 1];
958        Mock::given(method("POST"))
959            .respond_with(ResponseTemplate::new(200).set_body_bytes(oversized))
960            .mount(&server)
961            .await;
962
963        let Err(error) = HttpProvider::connect("remote", server.uri()).await else {
964            panic!("an oversized body must not be buffered");
965        };
966        let rendered = error.to_string();
967        assert!(
968            rendered.contains(&MAX_RESPONSE_BYTES.to_string()),
969            "the refusal names the limit it enforced: {rendered}"
970        );
971    }
972
973    /// A provider-written error body is clamped before it is interpolated into a
974    /// host error — a failing peer must not be a log-flooding primitive.
975    #[test]
976    fn a_provider_error_body_is_clamped_before_it_reaches_a_host_error() {
977        let short = "upstream index unavailable";
978        assert_eq!(
979            truncate_for_error(short),
980            short,
981            "short bodies pass through"
982        );
983
984        let flood = "E".repeat(100_000);
985        let clamped = truncate_for_error(&flood);
986        assert!(clamped.len() < 700, "clamped to {} bytes", clamped.len());
987        assert!(
988            clamped.contains("bytes truncated"),
989            "the clamp is disclosed, never silent: {clamped}"
990        );
991
992        // Multi-byte text is clamped on a char boundary, never mid-sequence.
993        let multibyte = "文".repeat(10_000);
994        let clamped = truncate_for_error(&multibyte);
995        assert!(clamped.contains("bytes truncated"));
996        assert!(clamped.is_char_boundary(0));
997    }
998}