Skip to main content

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}