tapes_capture/gateway.rs
1//! The capture-gateway environment contract, and the two protocols carried
2//! over it.
3//!
4//! This is the wire/environment agreement between a launching capture client
5//! and whatever runs inside the harness on the other end. It is **two
6//! sub-protocols**, and a reader who takes it for one will be surprised by the
7//! other:
8//!
9//! **1. Launch and trust.** [`GATEWAY_URL_ENV`] names the proxy,
10//! [`GATEWAY_SCHEMA_ENV`] hints at which upstream schema it fronts,
11//! [`GATEWAY_NONCE_ENV`] carries a per-launch secret, [`GATEWAY_NONCE_HEADER`]
12//! is where an installed plugin echoes that secret back, and
13//! [`nonce_matches`] is the constant-time comparison that decides whether the
14//! echo counts. This is what makes an inbound envelope believable at all —
15//! see [`crate::envelope::TapesAttribution::from_headers`] for the other half
16//! of that argument.
17//!
18//! **2. Per-provider routing.** A plugin can register more providers than a
19//! single-upstream proxy can serve. [`GATEWAY_PROVIDER_ROUTES_ENV`] (set to
20//! [`GATEWAY_PROVIDER_ROUTES_ON`]) tells the plugin to label each request with
21//! the provider it belongs to; [`provider_route`] builds the labelled base URL
22//! under [`GATEWAY_PROVIDER_ROUTE_PREFIX`], and [`split_provider_route`] is
23//! the proxy-side inverse. Unset, a plugin registers everything at the base URL
24//! unchanged, which is what a client predating this protocol gets.
25//!
26//! All nine items are re-exported at the crate root.
27//!
28//! # The variables a launching consumer sets
29//!
30//! Four of the seven live here, because they are protocol. The other three are
31//! presentation — what a product calls itself in a harness's status bar and
32//! what it tells a user to run — and live with the artifact that reads them, in
33//! the harness crate's `plugin::pi` module. A consumer wiring up a launch needs
34//! all seven; that full table is in the harness crate, on `plugin`, because
35//! only that crate can name both sets.
36//!
37//! It is protocol, not artifact. Adding another harness does not change any
38//! constant here — a new in-harness extension is written *against* this
39//! contract. That is why it lives beside the peer-trust check that consumes
40//! it rather than beside the plugin files that happen to be its first readers.
41
42/// Environment variable naming the capture-proxy base URL an installed plugin
43/// should send the harness's LLM traffic to.
44///
45/// Accepts a bare `host:port` or a full URL; the asset normalises it the same
46/// way a launch recipe's proxy endpoint does. Unset means "not captured", and
47/// an installed plugin must then leave the harness's own endpoints alone.
48pub const GATEWAY_URL_ENV: &str = "TAPES_GATEWAY_URL";
49
50/// Environment variable naming which upstream provider schema the capture proxy
51/// is currently fronting (e.g. `anthropic`, `openai`).
52///
53/// Optional, and a display/diagnostic hint only: a plugin may surface it and may
54/// warn when the user picks a model the proxy is not routing, but it must not
55/// gate the redirect on it. A proxy that fronts one schema at a time is one
56/// deployment shape, not a requirement of the contract.
57pub const GATEWAY_SCHEMA_ENV: &str = "TAPES_GATEWAY_SCHEMA";
58
59/// Environment variable declaring that the capture proxy serves each captured
60/// provider on its own route, so an installed plugin must say which provider a
61/// request belongs to.
62///
63/// Unset or empty means the proxy fronts a single upstream and a plugin
64/// registers every captured provider at [`GATEWAY_URL_ENV`] unchanged. Any
65/// other value means the plugin registers each provider at
66/// [`provider_route`] instead.
67///
68/// It exists because a plugin may register more providers than a
69/// single-upstream proxy can serve. pi's extension registers three, all at one
70/// base URL: with one upstream behind it, a session on any provider other than
71/// the one that upstream speaks is forwarded to a host that has never heard of
72/// the route, and the harness fails outright. Which of those two shapes a proxy
73/// is cannot be inferred from the address, so the launching client states it.
74///
75/// The default is the single-upstream shape on purpose: a client that predates
76/// this variable sets nothing, and gets exactly the requests it got before.
77pub const GATEWAY_PROVIDER_ROUTES_ENV: &str = "TAPES_GATEWAY_PROVIDER_ROUTES";
78
79/// The value a client sets in [`GATEWAY_PROVIDER_ROUTES_ENV`] to ask for
80/// per-provider routes.
81///
82/// A plugin treats *any* non-empty value as the request, so this is the
83/// spelling to write rather than the only one accepted — one less way for a
84/// client and an installed plugin to disagree.
85pub const GATEWAY_PROVIDER_ROUTES_ON: &str = "1";
86
87/// Path prefix under which a labelled request names its provider.
88///
89/// Underscore-led so it cannot collide with a provider API path: no upstream
90/// schema this contract covers serves anything beneath `/_tapes`.
91pub const GATEWAY_PROVIDER_ROUTE_PREFIX: &str = "/_tapes/provider";
92
93/// The path a request for `provider` is sent to when per-provider routes are
94/// on: [`GATEWAY_PROVIDER_ROUTE_PREFIX`], the provider name, then whatever path
95/// the harness's client would have used on its own.
96///
97/// The prefix is a *base URL* suffix rather than a header because it has to
98/// survive a harness client that composes its own paths — pi appends
99/// `/v1/messages` to whatever base URL a provider was registered with, and
100/// never consults a header the extension did not put on the request.
101#[must_use]
102pub fn provider_route(provider: &str) -> String {
103 format!("{GATEWAY_PROVIDER_ROUTE_PREFIX}/{provider}")
104}
105
106/// Split a labelled request path into the provider it names and the path the
107/// harness's client actually asked for.
108///
109/// `None` when the path carries no label at all, which a proxy must not read as
110/// "any provider will do": it means the request came from something that does
111/// not speak this half of the contract — an installed plugin older than the
112/// launching client, most likely — and the provider it wanted is simply not
113/// knowable from the request.
114///
115/// The remainder always begins with `/`, including for a bare
116/// `/_tapes/provider/<name>`, so a caller can concatenate it onto an upstream
117/// base without a second normalisation rule.
118#[must_use]
119pub fn split_provider_route(path: &str) -> Option<(&str, &str)> {
120 let rest = path.strip_prefix(GATEWAY_PROVIDER_ROUTE_PREFIX)?;
121 let rest = rest.strip_prefix('/')?;
122 let end = rest.find('/').unwrap_or(rest.len());
123 let (provider, remainder) = rest.split_at(end);
124 if provider.is_empty() {
125 return None;
126 }
127 Some((provider, if remainder.is_empty() { "/" } else { remainder }))
128}
129
130/// Environment variable carrying the per-launch capture nonce.
131///
132/// A self-attributing harness's `X-Tapes-*` envelope is a claim, and the
133/// ancestry check ([`crate::peer_trust`]) cannot tell the harness
134/// apart from the harness's *own subprocesses* — a command run by a shell tool
135/// is a descendant of the launched PID too, and could otherwise stamp another
136/// session's envelope. The launching consumer generates a
137/// fresh secret per capture, sets it in this variable for the harness process,
138/// and requires it echoed back before believing any envelope. The value must
139/// never be logged, forwarded upstream, or included in captured output.
140///
141/// An installed plugin must read this variable **once at load and delete it
142/// from its process environment immediately**, before any tool can run:
143/// subprocesses the harness later spawns inherit the harness's *current*
144/// environment, so the deletion keeps them from receiving the secret at all —
145/// it survives only in the plugin's own memory. With that in place the
146/// residual exposure is exactly two channels, and no more should be claimed:
147/// a same-UID process reading the harness's *original* environment out of
148/// `/proc/<pid>/environ` on Linux (that file snapshots the environment at
149/// `exec` and does not reflect the deletion), and anything the harness itself
150/// chooses to pass along explicitly.
151///
152/// Unset means the launching client predates the nonce contract; an installed
153/// plugin must then simply not send the header rather than fail.
154pub const GATEWAY_NONCE_ENV: &str = "TAPES_GATEWAY_NONCE";
155
156/// Request header in which an installed plugin echoes the capture nonce back
157/// to the proxy that launched it.
158///
159/// Lower-case for the same reason the `X-Tapes-*` envelope names are: HTTP/2
160/// lowercases header names on the wire, so the canonical spelling is the wire
161/// spelling. The header is a private channel between the extension and its own
162/// capture proxy — the proxy validates it against the value it generated and
163/// **strips it before forwarding**, so the nonce never reaches an upstream and
164/// never appears in a captured turn.
165pub const GATEWAY_NONCE_HEADER: &str = "x-tapes-gateway-nonce";
166
167/// Does a presented nonce match the one this capture generated?
168///
169/// Shared so both consumers enforce the same rule. Two properties matter:
170///
171/// * **An empty expectation never matches.** A consumer that failed to
172/// generate a nonce must fail closed, not accept an empty echo.
173/// * **The comparison is constant-time in the matching prefix.** The caller is
174/// a loopback listener reachable by every local process; a byte-at-a-time
175/// `==` would let one probe the secret through response timing. Length still
176/// leaks, and may: nonce lengths are not secret.
177#[must_use]
178pub fn nonce_matches(expected: &str, presented: &str) -> bool {
179 let expected = expected.as_bytes();
180 let presented = presented.as_bytes();
181 if expected.is_empty() || expected.len() != presented.len() {
182 return false;
183 }
184 expected
185 .iter()
186 .zip(presented)
187 .fold(0u8, |acc, (a, b)| acc | (a ^ b))
188 == 0
189}
190
191#[cfg(test)]
192#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
193mod tests {
194 use super::*;
195
196 /// A route this module builds is a route it takes apart again, for every
197 /// path shape a harness client actually produces.
198 #[test]
199 fn a_built_route_round_trips_through_the_split() {
200 for provider in ["anthropic", "openai", "openai-codex"] {
201 for path in ["/v1/messages", "/v1/responses", "/v1/models?limit=1"] {
202 let joined = format!("{}{path}", provider_route(provider));
203 assert_eq!(
204 split_provider_route(&joined),
205 Some((provider, path)),
206 "round trip failed for {joined}"
207 );
208 }
209 }
210 // A client that appended nothing still names a provider, and the
211 // remainder is a path rather than the empty string — so a caller can
212 // concatenate it without a second rule for this case.
213 assert_eq!(
214 split_provider_route(&provider_route("anthropic")),
215 Some(("anthropic", "/")),
216 );
217 }
218
219 /// An unlabelled path is `None` rather than a guess. The proxy's whole
220 /// reason for asking is that it cannot otherwise tell which upstream a
221 /// request wants, and a default here would put the guess back.
222 #[test]
223 fn a_path_that_names_no_provider_resolves_to_nothing() {
224 for path in [
225 "/v1/messages",
226 "/",
227 // The prefix with no name after it names no provider.
228 GATEWAY_PROVIDER_ROUTE_PREFIX,
229 "/_tapes/provider/",
230 // A *different* `_tapes` route is not a provider label; the proxy
231 // serves its own paths under that namespace.
232 "/_tapes/codex-app/lifecycle",
233 // Prefix-of-a-longer-segment must not match: `providerX` is not
234 // the provider route.
235 "/_tapes/providerX/anthropic/v1/messages",
236 ] {
237 assert_eq!(
238 split_provider_route(path),
239 None,
240 "{path} was read as labelled"
241 );
242 }
243 }
244
245 /// Fail closed on an unset expectation, and match only the exact echo.
246 #[test]
247 fn nonce_matching_is_exact_and_never_matches_an_empty_expectation() {
248 assert!(nonce_matches("abc123", "abc123"));
249 assert!(!nonce_matches("abc123", "abc124"));
250 assert!(!nonce_matches("abc123", "abc12"));
251 assert!(!nonce_matches("abc123", ""));
252 // A consumer that never generated a nonce must not accept an empty
253 // echo as a match — that would turn a misconfiguration into a bypass.
254 assert!(!nonce_matches("", ""));
255 assert!(!nonce_matches("", "abc123"));
256 }
257}