tapes_harnesses/plugin/pi.rs
1//! pi's capture extension: one installed file, branded at runtime.
2//!
3//! pi has no base-URL environment knob, so capture requires code running
4//! *inside* the harness: an extension that registers pi's providers against the
5//! capture proxy and stamps the `X-Tapes-*` envelope pi's turns are attributed
6//! by. That extension is harness knowledge — written against pi's extension API
7//! — and so it lives here, as [`super::PI_GATEWAY_EXTENSION`].
8//!
9//! # Why exactly one file
10//!
11//! pi auto-discovers global extensions by loading *every* file in
12//! `~/.pi/agent/extensions/`, into one process. That makes the number of
13//! installed copies a correctness property rather than a packaging detail.
14//!
15//! Two copies contend over everything the file touches. The nonce read is a
16//! read-and-delete, so the second copy to load finds nothing; worse, it
17//! registers the same three providers anyway, without the echo, and the last
18//! registration wins. The proxy then cannot tell a real launch from a forged
19//! envelope, and both products' sessions file as `unknown` with no error
20//! anywhere. Coordinating two copies — per-product variable names, a gate that
21//! stands one of them down — manages that collision. Installing one file to one
22//! path removes the second reader, and a collision needs two.
23//!
24//! So the asset is not rendered per consumer. Every client writes the same
25//! bytes to the same path, which is the property the opencode plugin has always
26//! had for free and the reason it was never exposed to this bug.
27//!
28//! # What a product may still say
29//!
30//! Its status entry's name, and what it tells a user to run when the proxy is
31//! fronting the wrong schema. Those are real differences and shipping different
32//! *bytes* was only ever one way to express them; the extension reads them from
33//! the environment of the launch instead — [`GATEWAY_LABEL_ENV`],
34//! [`GATEWAY_LABEL_SUFFIX_ENV`], [`GATEWAY_REMEDY_ENV`] — set by whichever
35//! client launched the session, for the length of that session.
36//!
37//! Runtime branding keeps the containment a rendered slot used to buy, and
38//! keeps it more cheaply: a value read from the environment is a string in a
39//! variable, so it cannot be syntax however it is spelled. It reaches
40//! `setStatus` and `notify` and nothing else — never the nonce handling, the
41//! envelope, or the provider registration — and the test
42//! `presentation_values_reach_only_the_status_entry_and_the_notification` pins
43//! that by reading the asset.
44//!
45//! What is *not* here is a default endpoint. The asset used to carry one, so a
46//! product running a long-lived proxy at a fixed address could capture pi
47//! sessions nobody launched under it. One file cannot hold one product's
48//! address without redirecting every other product's sessions there too, so the
49//! address moved entirely into the launch: a product that wants uncaptured pi
50//! sessions routed anyway sets [`super::GATEWAY_URL_ENV`] in the environment
51//! those sessions inherit, where the claim is explicit and revocable.
52//!
53//! The environment, nonce, and schema contract stays wholly crate-owned. There
54//! is no product-supplied name anywhere in it, which is what makes the shared
55//! spellings in [`super`] safe again.
56
57/// Environment variable naming the pi status entry this extension registers,
58/// and the prefix of the label shown in it.
59///
60/// Display text, set by the launching client. A short product word — the
61/// crate's own asset falls back to [`DEFAULT_LABEL`] when nothing set it.
62pub const GATEWAY_LABEL_ENV: &str = "TAPES_GATEWAY_LABEL";
63
64/// Environment variable appended to the status label after the active schema.
65///
66/// Display text. Exists because a product may need to say something about its
67/// own routing that the schema name alone does not carry — a proxy that also
68/// fronts a provider outside the active schema, say. Unset and empty mean the
69/// same thing: nothing appended.
70pub const GATEWAY_LABEL_SUFFIX_ENV: &str = "TAPES_GATEWAY_LABEL_SUFFIX";
71
72/// Environment variable carrying the sentence appended to the extension's
73/// schema-mismatch warning.
74///
75/// Display text. The diagnosis is the asset's; only the remedy is the
76/// launching client's, because only that client knows what command switches
77/// its proxy. Unset falls back to a sentence phrased in terms of
78/// [`super::GATEWAY_URL_ENV`], since the crate's own asset may name no product.
79pub const GATEWAY_REMEDY_ENV: &str = "TAPES_GATEWAY_REMEDY";
80
81/// The status label the asset presents when [`GATEWAY_LABEL_ENV`] is unset.
82pub const DEFAULT_LABEL: &str = "tapes";
83
84#[cfg(test)]
85#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
86mod tests {
87 use super::super::{
88 GATEWAY_NONCE_ENV, GATEWAY_NONCE_HEADER, GATEWAY_PROVIDER_ROUTE_PREFIX,
89 GATEWAY_PROVIDER_ROUTES_ENV, GATEWAY_SCHEMA_ENV, GATEWAY_URL_ENV, PI_GATEWAY_EXTENSION,
90 provider_route, split_provider_route,
91 };
92 use super::*;
93
94 /// The asset, as the only thing there is to inspect. There is no renderer
95 /// any more: what a consumer installs is exactly these bytes.
96 fn asset() -> &'static str {
97 PI_GATEWAY_EXTENSION.contents()
98 }
99
100 /// The value of a `const NAME = "…";` declaration in the asset.
101 ///
102 /// Reading the declaration rather than matching a substring is what makes
103 /// the pins below say "this name and no other": an asset that kept a stale
104 /// spelling *alongside* the right one would satisfy a `contains` and fail
105 /// here.
106 fn declared_const(name: &str) -> String {
107 let prefix = format!("const {name} = \"");
108 let at = asset()
109 .find(&prefix)
110 .unwrap_or_else(|| panic!("the asset declares no {name}"));
111 let rest = &asset()[at + prefix.len()..];
112 let end = rest
113 .find("\";")
114 .unwrap_or_else(|| panic!("the asset's {name} declaration is unterminated"));
115 rest[..end].to_string()
116 }
117
118 /// The asset's code, one line per line, with line comments and blank lines
119 /// removed.
120 ///
121 /// The containment test below asks what a value *reaches*, and prose about
122 /// what it must not reach would otherwise answer for it.
123 fn code_lines() -> Vec<String> {
124 asset()
125 .lines()
126 .map(|line| match line.find("//") {
127 // `http://` is the one `//` inside code here, and truncating at
128 // it leaves the identifiers this test looks for intact.
129 Some(at) => line[..at].to_string(),
130 None => line.to_string(),
131 })
132 .filter(|line| !line.trim().is_empty())
133 .collect()
134 }
135
136 /// **The one-artifact fix, stated as a property.** The asset is not a
137 /// template
138 /// and has no consumer-varying bytes: what any client installs is this
139 /// file, so a second installed reader of the pi extension directory cannot
140 /// exist for the copies to contend over.
141 ///
142 /// A reintroduced renderer would show up here as a placeholder left in the
143 /// shipped asset — which is precisely what the old two-branding model wrote
144 /// into it.
145 #[test]
146 fn the_asset_is_a_finished_file_and_not_a_template() {
147 assert!(
148 !asset().contains("__TAPES_"),
149 "the asset carries a slot placeholder; it is a template again, and \
150 a template means one rendering per product means two installed files"
151 );
152 }
153
154 /// The environment contract, pinned as whole declarations.
155 ///
156 /// The asset reads the environment by name, so the Rust constant and the
157 /// literal in the asset are two spellings of one contract; renaming the
158 /// constant alone would leave an installed extension waiting for a variable
159 /// nobody sets, which is a silently uncaptured session rather than a build
160 /// failure. Pinned as the entire `const … = "…";` declaration because the
161 /// per-product namespacing this change replaces is exactly the kind of
162 /// second spelling a `contains` would let through.
163 #[test]
164 fn the_asset_reads_the_shared_gateway_environment_contract() {
165 assert_eq!(declared_const("GATEWAY_URL_ENV"), GATEWAY_URL_ENV);
166 assert_eq!(declared_const("GATEWAY_SCHEMA_ENV"), GATEWAY_SCHEMA_ENV);
167 assert_eq!(declared_const("GATEWAY_NONCE_ENV"), GATEWAY_NONCE_ENV);
168 assert_eq!(declared_const("GATEWAY_NONCE_HEADER"), GATEWAY_NONCE_HEADER);
169 }
170
171 /// The presentation contract, pinned the same way. These are the names that
172 /// replaced the render-time slots, so a client that sets them and an asset
173 /// that reads something else is the failure mode: a status entry stuck on
174 /// the neutral fallback, with capture otherwise working.
175 #[test]
176 fn the_asset_reads_the_presentation_contract_at_runtime() {
177 assert_eq!(declared_const("GATEWAY_LABEL_ENV"), GATEWAY_LABEL_ENV);
178 assert_eq!(
179 declared_const("GATEWAY_LABEL_SUFFIX_ENV"),
180 GATEWAY_LABEL_SUFFIX_ENV
181 );
182 assert_eq!(declared_const("GATEWAY_REMEDY_ENV"), GATEWAY_REMEDY_ENV);
183 assert_eq!(declared_const("DEFAULT_LABEL"), DEFAULT_LABEL);
184 // …and each declared name is actually read, so the pins above cannot
185 // pass against a variable the asset merely names.
186 for identifier in [
187 "GATEWAY_LABEL_ENV",
188 "GATEWAY_LABEL_SUFFIX_ENV",
189 "GATEWAY_REMEDY_ENV",
190 ] {
191 assert!(
192 asset().contains(&format!("process.env[{identifier}]")),
193 "the asset declares {identifier} but never reads it"
194 );
195 }
196 }
197
198 /// The per-provider routing contract, pinned as whole declarations for the
199 /// same reason the rest of the environment contract is: the asset and the
200 /// launching client are two spellings of one agreement, and a rename on one
201 /// side alone is a session that routes every provider to one upstream while
202 /// the proxy waits for labels.
203 #[test]
204 fn the_asset_reads_the_provider_routing_contract() {
205 assert_eq!(
206 declared_const("GATEWAY_PROVIDER_ROUTES_ENV"),
207 GATEWAY_PROVIDER_ROUTES_ENV
208 );
209 assert_eq!(
210 declared_const("PROVIDER_ROUTE_PREFIX"),
211 GATEWAY_PROVIDER_ROUTE_PREFIX
212 );
213 assert!(
214 asset().contains(&format!("process.env[{}]", "GATEWAY_PROVIDER_ROUTES_ENV")),
215 "the asset declares the routing variable but never reads it"
216 );
217 }
218
219 /// **The property an internally routing gateway depends on.** A launcher
220 /// that sets
221 /// nothing must get the requests it got before this existed — same base
222 /// URL, same path — because a gateway that routes internally would receive
223 /// a labelled path it has no route for and fail every turn.
224 ///
225 /// Stated against the asset's own conditional rather than by executing it:
226 /// the label is built in exactly one place, and that place is gated on the
227 /// variable. An edit that labelled unconditionally would have to move the
228 /// construction out of the ternary, which fails here.
229 #[test]
230 fn a_launcher_that_asks_for_nothing_gets_unlabelled_registrations() {
231 let opening = "providerRoutes ? `";
232 let at = asset()
233 .find(opening)
234 .expect("the asset does not gate the provider label on the routing variable");
235 let rest = &asset()[at + opening.len()..];
236 let labelled = &rest[..rest.find('`').expect("unterminated provider base URL")];
237 assert_eq!(labelled, "${baseUrl}${PROVIDER_ROUTE_PREFIX}/${provider}");
238 // …and the other arm is the bare base URL, unchanged.
239 let otherwise = rest[rest.find('`').unwrap() + 1..]
240 .trim_start()
241 .strip_prefix(':')
242 .expect("the routing conditional has no unlabelled arm")
243 .trim_start();
244 assert!(
245 otherwise.starts_with("baseUrl"),
246 "the unlabelled arm is not the bare base URL: {otherwise:?}"
247 );
248 }
249
250 /// The active schema stops being a constraint once every provider has its
251 /// own route, so the mismatch warning must stand down with it. Left in, it
252 /// tells a user whose session is being captured correctly that it is not.
253 #[test]
254 fn the_schema_mismatch_warning_stands_down_under_provider_routes() {
255 assert!(
256 asset().contains("if (!providerRoutes && schemaProvider &&"),
257 "the schema-mismatch warning still fires when the proxy routes \
258 every provider it registers"
259 );
260 }
261
262 /// The two halves of the route shape agree: what the asset builds is what
263 /// [`split_provider_route`] takes apart. The asset composes its path in
264 /// TypeScript and the proxy parses it in Rust, so nothing but a test can
265 /// hold the two spellings together.
266 #[test]
267 fn the_route_the_asset_builds_is_the_route_the_contract_parses() {
268 for provider in ["anthropic", "openai", "openai-codex"] {
269 let base = provider_route(provider);
270 assert!(
271 base.starts_with(GATEWAY_PROVIDER_ROUTE_PREFIX),
272 "{base} is not under the declared prefix"
273 );
274 // pi appends its own path to the registered base URL; the join is
275 // what actually reaches the proxy.
276 let requested = format!("{base}/v1/messages");
277 let (labelled, rest) = split_provider_route(&requested)
278 .expect("a route this contract built is not one it parses");
279 assert_eq!(labelled, provider);
280 assert_eq!(rest, "/v1/messages");
281 }
282 }
283
284 /// **The containment property**, and the reason runtime branding is safe to
285 /// hand a product at all: a value the launching client sets reaches the
286 /// status entry and the notification, and nothing else.
287 ///
288 /// The rendered-slot model needed this proven against deliberately hostile
289 /// values, because a rendered value became *syntax* in a file. A runtime
290 /// value cannot: it is a string in a variable however it is spelled. What
291 /// is still worth pinning is where the file lets those variables go — a
292 /// later edit that interpolated the product's label into the envelope, or
293 /// used it to build the base URL, would hand a display string authority
294 /// over attribution.
295 #[test]
296 fn presentation_values_reach_only_the_status_entry_and_the_notification() {
297 let sensitive = [
298 "registerProvider",
299 "GATEWAY_NONCE_HEADER",
300 "nonce",
301 "baseUrl",
302 "X-Tapes-",
303 "envelope",
304 "headers",
305 ];
306 for identifier in ["statusLabel", "statusSuffix", "schemaRemedy"] {
307 let lines: Vec<String> = code_lines()
308 .into_iter()
309 .filter(|line| line.contains(identifier))
310 .collect();
311 assert!(
312 lines.len() >= 2,
313 "{identifier} is declared but never used; this test would pass vacuously"
314 );
315 for (line, token) in lines
316 .iter()
317 .flat_map(|line| sensitive.iter().copied().map(move |token| (line, token)))
318 {
319 assert!(
320 !line.contains(token),
321 "{identifier} reaches {token:?} on {line:?}; a display string \
322 must not touch the capture path"
323 );
324 }
325 }
326 }
327
328 /// The status label a product sees, still built the way it was before the
329 /// slots became environment reads. For a product whose label is `acme`, the
330 /// composed value is `acme:anthropic+codex` exactly — a string users read in
331 /// a status bar and match on, so the pieces, their order, and the separator
332 /// are all observable.
333 ///
334 /// The expected value is reconstructed from the template literal *in the
335 /// asset*, so reordering the pieces there fails here instead of quietly
336 /// producing `anthropic:acme+codex`.
337 #[test]
338 fn the_status_label_is_composed_exactly_as_it_was_when_it_was_rendered() {
339 let opening = "ctx.ui.setStatus(statusLabel, `";
340 let at = asset()
341 .find(opening)
342 .expect("the asset does not set a status entry from the runtime label");
343 let rest = &asset()[at + opening.len()..];
344 let pattern = &rest[..rest.find("`);").expect("unterminated status label")];
345
346 assert_eq!(pattern, "${statusLabel}:${activeSchema}${statusSuffix}");
347 let label = pattern
348 .replace("${statusLabel}", "acme")
349 .replace("${activeSchema}", "anthropic")
350 .replace("${statusSuffix}", "+codex");
351 assert_eq!(
352 label, "acme:anthropic+codex",
353 "the label a consumer's launch presents has changed"
354 );
355 }
356
357 /// An unset presentation variable must leave the asset saying something,
358 /// and something vendor-neutral: these bytes install into every client,
359 /// including ones that set none of the three.
360 #[test]
361 fn the_fallbacks_are_neutral_and_name_the_variable_a_user_would_set() {
362 assert_eq!(declared_const("DEFAULT_LABEL"), DEFAULT_LABEL);
363 let remedy = asset()
364 .split_once("const DEFAULT_REMEDY =")
365 .expect("the asset declares no DEFAULT_REMEDY")
366 .1;
367 let remedy = &remedy[..remedy.find(';').expect("unterminated DEFAULT_REMEDY")];
368 assert!(
369 remedy.contains(GATEWAY_URL_ENV),
370 "the neutral remedy does not name {GATEWAY_URL_ENV}, so it tells a \
371 user nothing they can act on"
372 );
373 }
374
375 /// The nonce contract in full, against the one asset there now is: read
376 /// once, deleted before any tool can run, echoed under the crate's header
377 /// name, and in that order. Unchanged by this fix, and pinned so it stays
378 /// that way — the delete is what keeps shell-tool children from inheriting
379 /// the secret, and it is the read the *second* installed copy used to lose.
380 #[test]
381 fn the_asset_reads_deletes_and_echoes_the_nonce_in_that_order() {
382 assert!(
383 asset().contains("const nonce = process.env[GATEWAY_NONCE_ENV];"),
384 "the asset does not read the nonce from the environment"
385 );
386 assert!(
387 asset().contains("delete process.env[GATEWAY_NONCE_ENV];"),
388 "the asset does not delete the nonce from its environment; \
389 shell-tool subprocesses would inherit the secret"
390 );
391 assert!(
392 asset().contains("[GATEWAY_NONCE_HEADER]: nonce"),
393 "the asset does not echo the nonce under the header name"
394 );
395 let read = asset()
396 .find("process.env[GATEWAY_NONCE_ENV]")
397 .unwrap_or(usize::MAX);
398 let delete = asset()
399 .find("delete process.env[GATEWAY_NONCE_ENV]")
400 .unwrap_or(0);
401 assert!(
402 read < delete,
403 "the asset deletes the nonce before it reads it"
404 );
405 }
406
407 /// The default endpoint is gone, and must stay gone. One file installed by
408 /// every client cannot carry one client's address: it would redirect every
409 /// other client's uncaptured pi sessions to that address too. Absence of a
410 /// loopback literal is the cheapest durable check that it has not come
411 /// back as a constant.
412 #[test]
413 fn the_asset_has_no_built_in_endpoint_to_fall_back_to() {
414 for literal in ["127.0.0.1", "localhost:", "DEFAULT_GATEWAY_URL"] {
415 assert!(
416 !asset().contains(literal),
417 "the asset carries {literal:?}; it must be inert without {GATEWAY_URL_ENV}"
418 );
419 }
420 assert!(
421 asset().contains("const rawBaseUrl = process.env[GATEWAY_URL_ENV];"),
422 "the asset must take its address from the launch and nowhere else"
423 );
424 }
425}