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