Skip to main content

tapes_harnesses/
plugin.rs

1//! Plugin artifacts — the files that must be installed *into* a harness before
2//! its traffic can be captured at all.
3//!
4//! Most harnesses need nothing here: capture works by pointing the harness's
5//! base-URL knob at a proxy, which [`crate::launch`] plans. A harness with no
6//! such knob needs code running inside it instead, and that code is an asset
7//! somebody has to write to disk. This module owns those assets, and — because
8//! *how many* copies of an asset end up in a harness's auto-discovery directory
9//! is a correctness property, not a packaging detail — it owns the install too,
10//! through [`PluginArtifact::install`].
11//!
12//! # Why the assets live here
13//!
14//! An in-harness extension is harness knowledge in the most literal sense — it
15//! is written against the harness's own extension API. Keeping it in a
16//! consumer's repository meant every consumer that wanted to capture that
17//! harness had to carry its own copy, and the copies would drift in exactly the
18//! way this crate exists to prevent. The asset moved here so
19//! `tapesctl plugin install` and a closed-source client install the same bytes.
20//!
21//! # What still must not be vendored
22//!
23//! The move is conditional on the asset being **vendor-neutral**, which was not
24//! free: the extension that seeded [`PI_GATEWAY_EXTENSION`] read a product's
25//! environment variables, defaulted to that product's daemon port, and told the
26//! user to run that product's CLI. All three are gone. What a crate-owned asset
27//! may know is that *a* capture proxy exists and how to talk to one; where that
28//! proxy is, what it is called, and how a user manages it stay with whoever
29//! installs the asset.
30//!
31//! Concretely, an asset here may not carry a vendor's name, a vendor's default
32//! endpoint, or a vendor's environment-variable spelling — it reads
33//! [`GATEWAY_URL_ENV`] and nothing else. A consumer whose plugin genuinely
34//! cannot be de-branded keeps that plugin in its own repository; it does not get
35//! a variant of [`crate::harness::PluginDelivery`] here.
36//!
37//! # …and what a consumer may still choose
38//!
39//! De-branding is not the same as having nothing to say. A consumer's status
40//! label and the command it tells a user to run are legitimately its own, and a
41//! consumer that had to fork a whole asset to express them would be back where
42//! this module started.
43//!
44//! An asset resolves that at **runtime**, not by being rendered: the launching
45//! consumer sets those strings in the environment of the launch it owns, and
46//! the asset reads them (see [`pi`] for pi's three). Rendering per consumer is
47//! the thing this module now refuses for a file-copy artifact, and for a
48//! structural reason — a rendered asset is one *file per product*, and a
49//! harness that auto-loads every file in a directory then loads two of them
50//! into one process, where they contend over the launch nonce and over the
51//! provider registrations and silently unattribute both products' sessions.
52//! One artifact, one path, identical bytes is what makes that second reader
53//! impossible rather than merely coordinated.
54//!
55//! [`codex_app`] is still rendered, and can be: its manifests are installed by
56//! the harness's own plugin manager into a per-consumer plugin, not copied into
57//! a directory something globs.
58//!
59//! # The environment contract
60//!
61//! An installed artifact is inert until the launching consumer sets
62//! [`GATEWAY_URL_ENV`]. That is deliberate: an artifact installs globally into
63//! the harness's own extension directory, so it loads for every session on the
64//! machine — including sessions nobody is capturing. Making the redirect
65//! conditional on the environment is what keeps an install from changing the
66//! behaviour of sessions the user did not launch under capture.
67//!
68//! The names are shared across consumers, and that is safe for exactly the
69//! reason above: one installed artifact means one reader per harness, so there
70//! is nothing to collide with. Per-consumer variable names buy nothing once the
71//! second copy is gone, and cost a launcher that can set a variable its
72//! installed asset does not read.
73//!
74//! ## All seven variables
75//!
76//! The contract is split across two crates, and this is the only place that
77//! lists it whole — a consumer wiring up a launch needs every row. The split is
78//! the crate boundary doing its job: the first four are *protocol*, true of any
79//! capture proxy and so owned by [`tapes_capture::gateway`]; the last three are
80//! *presentation*, which is a product's own and so lives with the artifact that
81//! reads them.
82//!
83//! | variable | const | set by | meaning |
84//! | --- | --- | --- | --- |
85//! | `TAPES_GATEWAY_URL` | [`GATEWAY_URL_ENV`] | launcher | Where to send the harness's LLM traffic. **Unset means "not captured"** — the artifact leaves the harness's own endpoints alone, which is what keeps a global install from touching sessions nobody launched under capture. |
86//! | `TAPES_GATEWAY_SCHEMA` | [`tapes_capture::gateway::GATEWAY_SCHEMA_ENV`] | launcher | Which upstream schema the proxy fronts (`anthropic`, `openai`). A display and diagnostic hint; an artifact must not gate the redirect on it. |
87//! | `TAPES_GATEWAY_NONCE` | [`GATEWAY_NONCE_ENV`] | launcher | The per-launch secret. Read once at load and deleted from the process environment immediately, so the harness's own subprocesses never receive it. |
88//! | `TAPES_GATEWAY_PROVIDER_ROUTES` | [`tapes_capture::gateway::GATEWAY_PROVIDER_ROUTES_ENV`] | launcher | Set to `1` when the proxy serves each provider on its own route. Unset is the single-upstream shape, which is what a launcher predating this variable gets. |
89//! | `TAPES_GATEWAY_LABEL` | [`pi::GATEWAY_LABEL_ENV`] | launcher | The product word shown in pi's status entry. |
90//! | `TAPES_GATEWAY_LABEL_SUFFIX` | [`pi::GATEWAY_LABEL_SUFFIX_ENV`] | launcher | Appended to the status label after the active schema. |
91//! | `TAPES_GATEWAY_REMEDY` | [`pi::GATEWAY_REMEDY_ENV`] | launcher | The sentence appended to a schema-mismatch warning — the diagnosis is the asset's, the remedy is the launcher's, because only it knows which command switches its proxy. |
92//!
93//! Every one of the seven is optional, and every one has a defined unset
94//! behaviour. That is not politeness: an artifact installs globally and loads
95//! for every session on the machine, so "nothing set" has to be a working
96//! configuration rather than an error.
97//!
98//! One request the harness makes on its own behalf goes back the other way:
99//! [`tapes_capture::gateway::GATEWAY_NONCE_HEADER`] is the request header in
100//! which the artifact echoes the nonce, and
101//! [`tapes_capture::gateway::nonce_matches`] is what the proxy compares it
102//! with.
103
104use std::path::{Path, PathBuf};
105
106pub mod codex_app;
107pub mod pi;
108mod slots;
109
110// The capture-gateway environment contract and the launch-nonce protocol moved
111// to `tapes-capture`. They are protocol, not artifact: adding a harness does not
112// change a constant there, and an in-harness extension is written *against* the
113// contract rather than being part of it. Keeping the two in one file is what let
114// a protocol change ride along with an artifact change.
115//
116// Re-exported at their original paths so a consumer pinning this crate by git
117// rev is not forced to move in lockstep; the canonical spelling is
118// `tapes_capture::gateway::…`.
119pub use tapes_capture::gateway::{
120    GATEWAY_NONCE_ENV, GATEWAY_NONCE_HEADER, GATEWAY_PROVIDER_ROUTE_PREFIX,
121    GATEWAY_PROVIDER_ROUTES_ENV, GATEWAY_PROVIDER_ROUTES_ON, GATEWAY_SCHEMA_ENV, GATEWAY_URL_ENV,
122    nonce_matches, provider_route, split_provider_route,
123};
124
125/// One file a consumer installs into a harness.
126///
127/// The destination is expressed as components relative to the user's home
128/// directory rather than an absolute path, for the same reason
129/// [`crate::launch`] recipes never pick a location in the user's home: the crate
130/// states *where within a home* the harness looks, and the consumer supplies the
131/// home it is installing into — which is also what makes the whole thing
132/// testable against a temporary directory.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub struct PluginArtifact {
135    file_name: &'static str,
136    install_dir: &'static [&'static str],
137    superseded_file_names: &'static [&'static str],
138    contents: &'static str,
139}
140
141impl PluginArtifact {
142    /// The file name to write, without any directory part.
143    #[must_use]
144    pub const fn file_name(&self) -> &'static str {
145        self.file_name
146    }
147
148    /// The install directory's path components, relative to the user's home.
149    ///
150    /// Exposed so an installer can describe the destination — in a dry run, say
151    /// — without having a home directory to resolve against.
152    #[must_use]
153    pub const fn install_dir_components(&self) -> &'static [&'static str] {
154        self.install_dir
155    }
156
157    /// The asset's full contents, embedded at compile time.
158    #[must_use]
159    pub const fn contents(&self) -> &'static str {
160        self.contents
161    }
162
163    /// The directory this artifact installs into, beneath `home`.
164    #[must_use]
165    pub fn install_dir(&self, home: &Path) -> PathBuf {
166        self.install_dir
167            .iter()
168            .fold(home.to_path_buf(), |path, component| path.join(component))
169    }
170
171    /// The full path this artifact installs to, beneath `home`.
172    #[must_use]
173    pub fn install_path(&self, home: &Path) -> PathBuf {
174        self.install_dir(home).join(self.file_name)
175    }
176
177    /// File names in this artifact's own install directory that a previous
178    /// release of *some* client wrote, and that installing this artifact must
179    /// remove.
180    ///
181    /// Only meaningful for a harness that loads a directory rather than a file:
182    /// there a superseded copy is not merely stale, it is a second reader, and
183    /// it keeps running the behaviour this artifact replaced. pi is that
184    /// harness, and its list names a file another client shipped — which is the
185    /// whole reason the list is crate-owned. A client can be expected to know
186    /// what *it* used to install; it cannot be expected to know what its
187    /// competitor did, and removing only one's own leaves the collision intact
188    /// from the other direction.
189    ///
190    /// This is the one place a vendor's name may appear in this module. It is
191    /// not carried into anything installed — it names bytes being deleted, not
192    /// bytes being written — and the vendor-neutrality bar on
193    /// [`PluginArtifact::contents`] is unaffected.
194    #[must_use]
195    pub const fn superseded_file_names(&self) -> &'static [&'static str] {
196        self.superseded_file_names
197    }
198
199    /// The paths [`Self::install`] removes, beneath `home`.
200    ///
201    /// Exposed for a consumer that owns its own write path — a content-keyed
202    /// refresh, say — and needs the removal without the write.
203    #[must_use]
204    pub fn superseded_paths(&self, home: &Path) -> Vec<PathBuf> {
205        let dir = self.install_dir(home);
206        self.superseded_file_names
207            .iter()
208            .map(|name| dir.join(name))
209            .collect()
210    }
211
212    /// The path this artifact stages its bytes at, in `dir`, before renaming
213    /// them onto the name the harness loads.
214    ///
215    /// The name is deliberately not one the harness's glob matches, and it is
216    /// deliberately a sibling of the destination — which is what makes the
217    /// final rename a within-filesystem one, and so atomic.
218    fn staged_path(&self, dir: &Path) -> PathBuf {
219        dir.join(format!(".{}.{}.tmp", self.file_name, std::process::id()))
220    }
221
222    /// Write this artifact beneath `home`, creating its directory and removing
223    /// every superseded sibling. Returns the path written.
224    ///
225    /// The removal is not tidiness. A harness that auto-discovers a whole
226    /// directory loads a superseded copy alongside this one, and two copies of
227    /// a capture extension in one process destroy each other's attribution —
228    /// which means an install that only *wrote* would leave an upgrading user
229    /// exactly as broken as before, with the new bytes on disk to prove the fix
230    /// had shipped. Writing and removing therefore belong to one operation, not
231    /// to each consumer's good intentions.
232    ///
233    /// Belonging to one operation is a claim about the failures too, and it
234    /// constrains the order — because the state that must never be reached is
235    /// *both files present*, and writing first reaches it the moment a removal
236    /// fails. So the bytes are staged first under a name the harness's glob
237    /// cannot match, the superseded siblings are
238    /// removed second, and the staged file is renamed onto its final name last.
239    /// Each way that can fail leaves at most one extension where the harness
240    /// looks:
241    ///
242    /// - staging fails — nothing on disk changed;
243    /// - a superseded sibling exists and cannot be removed — the staged bytes
244    ///   are discarded and the error returned, so the user is left with the old
245    ///   copy still working rather than with a second reader;
246    /// - the rename fails — the superseded copy is gone and the new file never
247    ///   arrived, so capture is off, loudly, instead of on and silently
248    ///   unattributed.
249    ///
250    /// Staging under a non-matching name buys a second thing: a harness reads
251    /// that directory every time it starts a session, not once when an
252    /// installer runs, so a session starting mid-write must not be able to find
253    /// a half-written file spelled like something it loads.
254    ///
255    /// A superseded file that is absent is not an error. One that exists and
256    /// cannot be removed is: the caller has to know that the harness will still
257    /// load it, and is better placed than this crate to decide whether that
258    /// fails the launch or warns.
259    ///
260    /// # Errors
261    ///
262    /// Any I/O failure creating the directory, staging the bytes, removing a
263    /// superseded sibling that exists, or renaming the staged file into place.
264    /// An error after staging takes the staged file with it, so a failed
265    /// install never leaves debris in a directory the harness reads — but a
266    /// rename that failed has already removed the superseded copies, and the
267    /// caller is being told that nothing is installed.
268    pub fn install(&self, home: &Path) -> std::io::Result<PathBuf> {
269        let dir = self.install_dir(home);
270        std::fs::create_dir_all(&dir)?;
271        let staged = self.staged_path(&dir);
272        std::fs::write(&staged, self.contents)?;
273        for superseded in self.superseded_paths(home) {
274            match std::fs::remove_file(&superseded) {
275                Ok(()) => {}
276                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
277                Err(error) => {
278                    drop(std::fs::remove_file(&staged));
279                    return Err(error);
280                }
281            }
282        }
283        let path = dir.join(self.file_name);
284        if let Err(error) = std::fs::rename(&staged, &path) {
285            drop(std::fs::remove_file(&staged));
286            return Err(error);
287        }
288        Ok(path)
289    }
290}
291
292/// pi's capture extension.
293///
294/// pi has no base-URL environment knob, so there is nothing for a launch recipe
295/// to set: capture requires this extension registering pi's providers against
296/// the proxy from inside the harness. It is also what makes pi the
297/// [`crate::harness::AttributionStrategy::SelfAttributing`] harness — the
298/// `X-Tapes-*` headers it attaches are the only attribution pi's turns get,
299/// because no PID-indexed session file exists for a client to read.
300///
301/// pi auto-discovers global extensions from `~/.pi/agent/extensions/*.ts` — it
302/// loads *every* file there, into one process — so installing the file is the
303/// whole installation, and the number of files is part of the contract.
304///
305/// These bytes are what every client installs, to this one path. They used to
306/// be one rendering of a per-consumer template, which put two files in that
307/// directory on any machine with two clients: both registered the same
308/// providers, the second to load found the launch nonce already consumed and
309/// registered without the echo, and both products' sessions filed as `unknown`.
310/// What a product says differently it now says through the environment of its
311/// own launch — see [`pi`].
312///
313/// [`PluginArtifact::superseded_file_names`] carries the branded name that
314/// model left on disk, because an upgrading user has one and pi would go on
315/// loading it.
316pub const PI_GATEWAY_EXTENSION: PluginArtifact = PluginArtifact {
317    file_name: "tapes-gateway.ts",
318    install_dir: &[".pi", "agent", "extensions"],
319    superseded_file_names: &["paper-gateway.ts"],
320    contents: include_str!(concat!(
321        env!("CARGO_MANIFEST_DIR"),
322        "/assets/pi/tapes-gateway.ts"
323    )),
324};
325
326/// The artifact set for a harness captured by a bundled pi extension.
327pub(crate) const PI_ARTIFACTS: &[PluginArtifact] = &[PI_GATEWAY_EXTENSION];
328
329/// opencode's capture plugin.
330///
331/// opencode *can* be redirected without one — its provider endpoints live in a
332/// JSON config file, which is what [`crate::launch::OpenCodeRecipe`] plans —
333/// but a config file cannot attribute: opencode publishes no PID-indexed
334/// session file, so a redirected session's turns land under
335/// `harness_id: unknown`. This plugin is what closes that gap. It does both
336/// halves from inside the harness: a `config` hook points the captured
337/// providers at the proxy named by [`GATEWAY_URL_ENV`], and a `chat.headers`
338/// hook stamps the `X-Tapes-*` envelope with opencode's own session id plus
339/// the [`GATEWAY_NONCE_HEADER`] echo — which is what makes opencode the second
340/// [`crate::harness::AttributionStrategy::SelfAttributing`] harness.
341///
342/// opencode auto-discovers plugins by globbing `{plugin,plugins}/*.{ts,js}`
343/// under its global config directory (`~/.config/opencode`) and the project's
344/// `.opencode/`, so installing the file is the whole installation. The
345/// documented spelling of the directory is `plugins`, which is the one used
346/// here. The one soft spot is the root: opencode resolves its config directory
347/// through `$XDG_CONFIG_HOME`, and this artifact's destination is the
348/// *default* resolution of that variable — a user who relocated it installs by
349/// hand, exactly as they already do for every other opencode plugin.
350pub const OPENCODE_GATEWAY_EXTENSION: PluginArtifact = PluginArtifact {
351    file_name: "tapes-gateway.ts",
352    install_dir: &[".config", "opencode", "plugins"],
353    // Never rendered per consumer, so no client ever wrote a differently-named
354    // copy of it and there is nothing to supersede. This is the shape pi has
355    // now been given.
356    superseded_file_names: &[],
357    contents: include_str!(concat!(
358        env!("CARGO_MANIFEST_DIR"),
359        "/assets/opencode/tapes-gateway.ts"
360    )),
361};
362
363/// The artifact set for a harness captured by the bundled opencode plugin.
364pub(crate) const OPENCODE_ARTIFACTS: &[PluginArtifact] = &[OPENCODE_GATEWAY_EXTENSION];
365
366#[cfg(test)]
367#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
368mod tests {
369    use super::*;
370    use crate::harness::{PluginDelivery, REGISTRY};
371    use tapes_capture::envelope::{
372        HARNESS_ID_OPENCODE, HARNESS_ID_PI, X_TAPES_HARNESS_ID, X_TAPES_HARNESS_SESSION_ID,
373    };
374
375    /// Every artifact the crate ships, however it is reached from the registry.
376    fn all_artifacts() -> Vec<&'static PluginArtifact> {
377        REGISTRY
378            .iter()
379            .flat_map(|harness| harness.plugin_artifacts())
380            .collect()
381    }
382
383    /// The point of the whole module: an artifact must be installable, so the
384    /// registry has to actually reach one. A refactor that left every harness's
385    /// artifact list empty would otherwise pass every other test here
386    /// vacuously.
387    #[test]
388    fn the_registry_reaches_at_least_one_artifact() {
389        assert!(
390            !all_artifacts().is_empty(),
391            "no harness in the registry declares a plugin artifact"
392        );
393        assert!(all_artifacts().contains(&&PI_GATEWAY_EXTENSION));
394        assert!(all_artifacts().contains(&&OPENCODE_GATEWAY_EXTENSION));
395    }
396
397    /// An artifact's destination components are joined onto a caller-supplied
398    /// home. A component carrying a separator or a `..` would let that join
399    /// leave the home directory — the installer canonicalises and contains, but
400    /// the crate must not hand it something designed to escape in the first
401    /// place.
402    ///
403    /// Superseded names are held to the same bar, and more urgently: they are
404    /// joined the same way and then handed to `remove_file`, so a traversing
405    /// component there deletes something outside the harness's own directory.
406    #[test]
407    fn no_artifact_path_component_can_leave_the_home_directory() {
408        for artifact in all_artifacts() {
409            let components = artifact
410                .install_dir_components()
411                .iter()
412                .chain(std::iter::once(&artifact.file_name()))
413                .chain(artifact.superseded_file_names())
414                .copied()
415                .collect::<Vec<_>>();
416            for component in components {
417                assert!(!component.is_empty(), "empty component in {artifact:?}");
418                assert!(
419                    !component.contains('/') && !component.contains('\\'),
420                    "{component:?} is a path, not a component"
421                );
422                assert!(
423                    component != ".." && component != ".",
424                    "{component:?} traverses"
425                );
426            }
427        }
428    }
429
430    #[test]
431    fn an_artifact_resolves_beneath_the_home_it_is_given() {
432        let home = Path::new("/home/u");
433        assert_eq!(
434            PI_GATEWAY_EXTENSION.install_path(home),
435            PathBuf::from("/home/u/.pi/agent/extensions/tapes-gateway.ts"),
436        );
437        assert_eq!(
438            PI_GATEWAY_EXTENSION.install_dir(home),
439            PathBuf::from("/home/u/.pi/agent/extensions"),
440        );
441        // Different home, same shape — nothing is baked in at compile time.
442        assert!(
443            PI_GATEWAY_EXTENSION
444                .install_path(Path::new("/tmp/t"))
445                .starts_with("/tmp/t"),
446        );
447    }
448
449    /// **The one-artifact property.** pi loads every file in its extension
450    /// directory into one process, so two clients installing "their" copy is
451    /// two readers contending over one launch's nonce and over the same
452    /// provider registrations — and the loser registers anyway, unattributed.
453    /// The fix is that there is nothing per-client to install: whoever installs
454    /// writes these bytes, to this path.
455    ///
456    /// Two homes stand in for two clients. The relative destination and the
457    /// written bytes must match, because a difference in either is a second
458    /// file in that directory.
459    #[test]
460    fn every_client_installs_identical_bytes_to_one_path() {
461        let (first, second) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
462        let one = PI_GATEWAY_EXTENSION.install(first.path()).unwrap();
463        let two = PI_GATEWAY_EXTENSION.install(second.path()).unwrap();
464
465        assert_eq!(
466            one.strip_prefix(first.path()),
467            two.strip_prefix(second.path()),
468            "two installs disagree about where the pi extension goes"
469        );
470        assert_eq!(
471            std::fs::read_to_string(&one).unwrap(),
472            std::fs::read_to_string(&two).unwrap(),
473            "two installs wrote different bytes; a second reader can exist again"
474        );
475        assert_eq!(
476            std::fs::read_to_string(&one).unwrap(),
477            PI_GATEWAY_EXTENSION.contents(),
478        );
479    }
480
481    /// **The migration, and the half without which the fix reaches nobody.**
482    /// Every user who ran a client from before the assets were unified has that
483    /// client's branded extension sitting in pi's directory. Writing the new
484    /// file next to it leaves two extensions loaded and the bug exactly as it
485    /// was — with the fix installed, which is worse than not shipping it. So
486    /// installing removes the superseded names.
487    #[test]
488    fn installing_the_pi_extension_removes_a_superseded_branded_copy() {
489        let home = tempfile::tempdir().unwrap();
490        let dir = PI_GATEWAY_EXTENSION.install_dir(home.path());
491        std::fs::create_dir_all(&dir).unwrap();
492        let superseded = dir.join("paper-gateway.ts");
493        std::fs::write(&superseded, "// an older client's rendering\n").unwrap();
494
495        let installed = PI_GATEWAY_EXTENSION.install(home.path()).unwrap();
496
497        assert!(
498            !superseded.exists(),
499            "the superseded extension survived the install; pi would load both"
500        );
501        assert_eq!(
502            std::fs::read_to_string(&installed).unwrap(),
503            PI_GATEWAY_EXTENSION.contents(),
504        );
505        // …and nothing else in the directory was touched: the removal is a
506        // named list, not a sweep of a directory the user also puts their own
507        // extensions in.
508        assert_eq!(
509            std::fs::read_dir(&dir).unwrap().count(),
510            1,
511            "installing removed or added something it was not asked to"
512        );
513    }
514
515    /// **The ordering, in the direction that bites.** Writing the new file
516    /// first and removing second means a removal that fails leaves *both*
517    /// extensions in the directory — the precise state this artifact exists to
518    /// prevent, arrived at by the code meant to prevent it, and with the new
519    /// bytes on disk to argue the fix had shipped. Installing must instead fail
520    /// with the destination still empty, leaving the user the old copy that at
521    /// least works.
522    ///
523    /// The removal is blocked with a rule of the filesystem rather than a
524    /// permission bit: `remove_file` refuses a non-empty directory whoever is
525    /// asking, whereas CI runs as root, where a read-only file proves nothing.
526    #[test]
527    fn a_superseded_copy_that_cannot_be_removed_leaves_nothing_installed() {
528        let home = tempfile::tempdir().unwrap();
529        let dir = PI_GATEWAY_EXTENSION.install_dir(home.path());
530        std::fs::create_dir_all(&dir).unwrap();
531        // A non-empty directory standing where the superseded file goes: not
532        // removable by anyone, root included.
533        let superseded = dir.join("paper-gateway.ts");
534        std::fs::create_dir_all(&superseded).unwrap();
535        std::fs::write(superseded.join("occupant"), "unremovable\n").unwrap();
536
537        let error = PI_GATEWAY_EXTENSION.install(home.path()).unwrap_err();
538        assert_ne!(
539            error.kind(),
540            std::io::ErrorKind::NotFound,
541            "the blocker was not in place; the test proves nothing"
542        );
543
544        assert!(
545            !PI_GATEWAY_EXTENSION.install_path(home.path()).exists(),
546            "the install wrote its extension anyway, so pi would load two"
547        );
548        assert!(
549            superseded.exists(),
550            "the blocker vanished; the removal did not actually fail"
551        );
552        // …and the staged bytes went with the error, rather than sitting in a
553        // directory the harness reads on every session start.
554        assert_eq!(
555            std::fs::read_dir(&dir).unwrap().count(),
556            1,
557            "a failed install left debris in the extension directory"
558        );
559    }
560
561    /// The staged file exists for as long as the write takes, in a directory
562    /// the harness globs every time it starts a session — so its name must not
563    /// be one of the names that glob picks up. pi loads `*.ts` and opencode
564    /// `*.{ts,js}`; a partially written file spelled either way is an extension
565    /// the harness will happily load half of.
566    ///
567    /// Staging as a sibling is the other half: rename is only atomic within a
568    /// filesystem, and only a sibling is guaranteed to be on the same one.
569    #[test]
570    fn the_staged_name_is_not_one_a_harness_loads() {
571        let dir = Path::new("/home/u/.pi/agent/extensions");
572        for artifact in all_artifacts() {
573            let staged = artifact.staged_path(dir);
574            let name = staged.file_name().unwrap().to_str().unwrap();
575            assert!(
576                !name.ends_with(".ts"),
577                "{name:?} would be auto-loaded as an extension mid-write"
578            );
579            assert!(
580                !name.ends_with(".js"),
581                "{name:?} would be auto-loaded as a plugin mid-write"
582            );
583            assert_ne!(
584                name,
585                artifact.file_name(),
586                "staging onto the destination is not staging at all"
587            );
588            assert_eq!(
589                staged.parent(),
590                Some(dir),
591                "the staged file must be a sibling of its destination"
592            );
593        }
594    }
595
596    /// The list is named, so it has to actually name the file the bug is about.
597    /// The test above would pass just as happily against an empty list if it
598    /// created no superseded file.
599    #[test]
600    fn the_pi_artifact_names_the_branded_copy_an_upgrading_user_has() {
601        assert!(
602            PI_GATEWAY_EXTENSION
603                .superseded_file_names()
604                .contains(&"paper-gateway.ts"),
605            "nothing removes the file an older paper installed"
606        );
607        assert_eq!(
608            PI_GATEWAY_EXTENSION.superseded_paths(Path::new("/home/u")),
609            vec![PathBuf::from(
610                "/home/u/.pi/agent/extensions/paper-gateway.ts"
611            )],
612        );
613    }
614
615    /// A first install, onto a machine that has neither the directory nor a
616    /// superseded copy, is the ordinary case and must not error on the absent
617    /// file it was told to remove.
618    #[test]
619    fn installing_creates_the_directory_and_tolerates_nothing_to_supersede() {
620        let home = tempfile::tempdir().unwrap();
621        let installed = PI_GATEWAY_EXTENSION.install(home.path()).unwrap();
622        assert_eq!(
623            std::fs::read_to_string(&installed).unwrap(),
624            PI_GATEWAY_EXTENSION.contents(),
625        );
626    }
627
628    /// An artifact that superseded its own file name would delete what it had
629    /// just written, leaving the harness with no extension at all — and the
630    /// symptom (nothing captured) looks nothing like the cause.
631    #[test]
632    fn no_artifact_supersedes_the_file_it_installs() {
633        for artifact in all_artifacts() {
634            assert!(
635                !artifact
636                    .superseded_file_names()
637                    .contains(&artifact.file_name()),
638                "{} would delete itself on install",
639                artifact.file_name(),
640            );
641        }
642    }
643
644    #[test]
645    fn every_artifact_carries_its_bytes() {
646        for artifact in all_artifacts() {
647            assert!(
648                !artifact.contents().trim().is_empty(),
649                "{} is empty",
650                artifact.file_name(),
651            );
652        }
653    }
654
655    /// The de-branding pass, pinned. The asset was generalised out of a
656    /// vendor's repository, and the licence to keep it here is that it names no
657    /// vendor: a re-vendored branded copy, or a branded hint added later, fails
658    /// here rather than shipping to every consumer of the crate.
659    #[test]
660    fn no_artifact_carries_vendor_branding() {
661        for artifact in all_artifacts() {
662            let lowered = artifact.contents().to_ascii_lowercase();
663            for token in ["paper", "papercompute"] {
664                assert!(
665                    !lowered.contains(token),
666                    "{} mentions {token:?}; a crate-owned asset must be vendor-neutral",
667                    artifact.file_name(),
668                );
669            }
670        }
671    }
672
673    /// The asset reads the environment by name, so the Rust constant and the
674    /// literal in the asset are two spellings of one contract. Renaming the
675    /// constant alone would leave an installed plugin waiting for a variable
676    /// nobody sets — a silently uncaptured session, not a build failure.
677    ///
678    /// Pinned as the whole `const … = "…";` declaration rather than as a
679    /// substring. Per-product namespacing of these names was the shape of an
680    /// earlier attempt at the same fix, and a `contains` accepts an asset that
681    /// keeps such a name *alongside* the shared one — which is a launcher and
682    /// an extension agreeing on a variable nobody else sets.
683    #[test]
684    fn the_pi_extension_reads_the_gateway_environment_contract() {
685        let contents = PI_GATEWAY_EXTENSION.contents();
686        assert!(
687            contents.contains(&format!("const GATEWAY_URL_ENV = \"{GATEWAY_URL_ENV}\";")),
688            "the asset does not read {GATEWAY_URL_ENV}"
689        );
690        assert!(
691            contents.contains(&format!(
692                "const GATEWAY_SCHEMA_ENV = \"{GATEWAY_SCHEMA_ENV}\";"
693            )),
694            "the asset does not read {GATEWAY_SCHEMA_ENV}"
695        );
696    }
697
698    /// The nonce contract is asset-side too: the extension reads the secret
699    /// from the environment and echoes it in the header, and both names are
700    /// TypeScript literals that must be the same spellings as the Rust
701    /// constants a consumer generates and validates against. A drift in either
702    /// direction is a silent hole — the extension echoing a header nobody
703    /// checks, or the proxy demanding an echo nobody sends.
704    #[test]
705    fn the_pi_extension_echoes_the_capture_nonce_contract() {
706        let contents = PI_GATEWAY_EXTENSION.contents();
707        assert!(
708            contents.contains(&format!(
709                "const GATEWAY_NONCE_ENV = \"{GATEWAY_NONCE_ENV}\";"
710            )),
711            "the asset does not read {GATEWAY_NONCE_ENV}"
712        );
713        assert!(
714            contents.contains(GATEWAY_NONCE_HEADER),
715            "the asset does not echo the nonce in {GATEWAY_NONCE_HEADER}"
716        );
717        // And the echo is a real read-then-send, not just the names appearing:
718        // the asset must read the env by the constant's name and place the
719        // value under the header's name.
720        assert!(
721            contents.contains("process.env[GATEWAY_NONCE_ENV]"),
722            "the asset does not read the nonce from the environment"
723        );
724        assert!(
725            contents.contains("[GATEWAY_NONCE_HEADER]: nonce"),
726            "the asset does not place the nonce value under the header name"
727        );
728    }
729
730    /// The read must also be a *removal*. Subprocesses the harness spawns
731    /// inherit its current environment and already pass the ancestry check, so
732    /// a nonce left sitting in `process.env` hands every shell-tool child both
733    /// halves of the trust decision. The asset takes the value into its
734    /// closure and deletes the variable at load, before any tool can run —
735    /// and that delete is as load-bearing as the echo itself, so it is pinned
736    /// the same way the spellings are.
737    #[test]
738    fn the_pi_extension_deletes_the_nonce_from_its_environment_at_load() {
739        let contents = PI_GATEWAY_EXTENSION.contents();
740        assert!(
741            contents.contains("delete process.env[GATEWAY_NONCE_ENV]"),
742            "the asset does not delete the nonce from its environment; \
743             shell-tool subprocesses would inherit the secret"
744        );
745        // The delete must come after the one read into the closure — a delete
746        // alone would silence the echo entirely.
747        let read = contents
748            .find("process.env[GATEWAY_NONCE_ENV]")
749            .unwrap_or(usize::MAX);
750        let delete = contents
751            .find("delete process.env[GATEWAY_NONCE_ENV]")
752            .unwrap_or(0);
753        assert!(
754            read < delete,
755            "the asset must capture the nonce before deleting it"
756        );
757    }
758
759    /// pi stamps its own envelope, so the header names in the asset are the
760    /// crate's `X-Tapes-*` contract expressed in TypeScript. If
761    /// [`tapes_capture::envelope`] renames one, ingest would stop recognising pi's
762    /// self-attribution and its sessions would silently file as `unknown`.
763    #[test]
764    fn the_pi_extension_stamps_the_envelope_this_crate_defines() {
765        let lowered = PI_GATEWAY_EXTENSION.contents().to_ascii_lowercase();
766        assert!(
767            lowered.contains(&format!("\"{X_TAPES_HARNESS_ID}\": \"{HARNESS_ID_PI}\"")),
768            "the asset does not stamp {X_TAPES_HARNESS_ID}: {HARNESS_ID_PI}"
769        );
770        assert!(
771            lowered.contains(X_TAPES_HARNESS_SESSION_ID),
772            "the asset does not stamp {X_TAPES_HARNESS_SESSION_ID}"
773        );
774    }
775
776    /// A default endpoint is the specific branding failure that made the asset
777    /// un-shippable before: it pointed at one product's daemon port, so every
778    /// pi session on the machine was redirected there whether or not anything
779    /// was capturing. Absence of a loopback literal is the cheapest durable
780    /// check that it has not come back.
781    #[test]
782    fn the_pi_extension_has_no_built_in_endpoint() {
783        let contents = PI_GATEWAY_EXTENSION.contents();
784        for literal in ["127.0.0.1:", "localhost:", "http://127.0.0.1"] {
785            assert!(
786                !contents.contains(literal),
787                "the asset hard-codes {literal:?}; it must be inert without {GATEWAY_URL_ENV}"
788            );
789        }
790    }
791
792    // --- the opencode plugin, pinned the same way the pi extension is -------
793    //
794    // The two assets implement one environment contract against two different
795    // extension APIs, so each carries its own copy of the spellings and each
796    // copy is pinned independently: a drift in either asset is a silently
797    // uncaptured (or silently unattributed) harness, not a build failure.
798
799    #[test]
800    fn opencode_installs_where_opencode_discovers_plugins() {
801        // opencode globs `{plugin,plugins}/*.{ts,js}` beneath its config
802        // directory; `plugins` is the documented spelling. A destination
803        // outside that glob is an installed file opencode never loads.
804        let home = Path::new("/home/u");
805        assert_eq!(
806            OPENCODE_GATEWAY_EXTENSION.install_path(home),
807            PathBuf::from("/home/u/.config/opencode/plugins/tapes-gateway.ts"),
808        );
809    }
810
811    /// The asset reads the environment by name, so the Rust constant and the
812    /// literal in the asset are two spellings of one contract — same
813    /// reasoning as the pi test above, pinned against the opencode copy.
814    #[test]
815    fn the_opencode_plugin_reads_the_gateway_environment_contract() {
816        let contents = OPENCODE_GATEWAY_EXTENSION.contents();
817        assert!(
818            contents.contains(GATEWAY_URL_ENV),
819            "the asset does not read {GATEWAY_URL_ENV}"
820        );
821        assert!(
822            contents.contains(GATEWAY_SCHEMA_ENV),
823            "the asset does not read {GATEWAY_SCHEMA_ENV}"
824        );
825    }
826
827    /// The nonce contract, asset-side: read from the environment, echoed in
828    /// the header, both under the crate's spellings.
829    #[test]
830    fn the_opencode_plugin_echoes_the_capture_nonce_contract() {
831        let contents = OPENCODE_GATEWAY_EXTENSION.contents();
832        assert!(
833            contents.contains(GATEWAY_NONCE_ENV),
834            "the asset does not read {GATEWAY_NONCE_ENV}"
835        );
836        assert!(
837            contents.contains(GATEWAY_NONCE_HEADER),
838            "the asset does not echo the nonce in {GATEWAY_NONCE_HEADER}"
839        );
840        // And the echo is a real read-then-send: the asset reads the env by
841        // the constant's name and places the value under the header's name.
842        assert!(
843            contents.contains("process.env[GATEWAY_NONCE_ENV]"),
844            "the asset does not read the nonce from the environment"
845        );
846        assert!(
847            contents.contains("output.headers[GATEWAY_NONCE_HEADER] = nonce"),
848            "the asset does not place the nonce value under the header name"
849        );
850    }
851
852    /// The read must also be a *removal*, before any tool can run — the same
853    /// property pinned for pi, load-bearing for the same reason: shell-tool
854    /// children inherit the current environment and already pass the ancestry
855    /// check, so a lingering variable hands them both halves of the trust
856    /// decision. This asset does the read-and-delete at module load, which is
857    /// earlier still than pi's (inside the exported function).
858    #[test]
859    fn the_opencode_plugin_deletes_the_nonce_from_its_environment_at_load() {
860        let contents = OPENCODE_GATEWAY_EXTENSION.contents();
861        assert!(
862            contents.contains("delete process.env[GATEWAY_NONCE_ENV]"),
863            "the asset does not delete the nonce from its environment; \
864             shell-tool subprocesses would inherit the secret"
865        );
866        let read = contents
867            .find("process.env[GATEWAY_NONCE_ENV]")
868            .unwrap_or(usize::MAX);
869        let delete = contents
870            .find("delete process.env[GATEWAY_NONCE_ENV]")
871            .unwrap_or(0);
872        assert!(
873            read < delete,
874            "the asset must capture the nonce before deleting it"
875        );
876    }
877
878    /// opencode stamps its own envelope, so the header names in the asset are
879    /// the crate's `X-Tapes-*` contract expressed in TypeScript — a rename in
880    /// [`tapes_capture::envelope`] must fail here, not silently re-file opencode's
881    /// sessions as `unknown`.
882    #[test]
883    fn the_opencode_plugin_stamps_the_envelope_this_crate_defines() {
884        let lowered = OPENCODE_GATEWAY_EXTENSION.contents().to_ascii_lowercase();
885        assert!(
886            lowered.contains(&format!(
887                "\"{X_TAPES_HARNESS_ID}\": \"{HARNESS_ID_OPENCODE}\""
888            )),
889            "the asset does not stamp {X_TAPES_HARNESS_ID}: {HARNESS_ID_OPENCODE}"
890        );
891        assert!(
892            lowered.contains(X_TAPES_HARNESS_SESSION_ID),
893            "the asset does not stamp {X_TAPES_HARNESS_SESSION_ID}"
894        );
895    }
896
897    /// The nonce is a secret shared with the proxy alone, and the stamp runs
898    /// per request against whatever endpoint the provider actually resolved:
899    /// the asset must gate the echo on the request really routing through the
900    /// gateway, or an auth loader that swapped endpoints would carry the
901    /// secret to a real upstream.
902    #[test]
903    fn the_opencode_plugin_stamps_nothing_toward_a_real_upstream() {
904        let contents = OPENCODE_GATEWAY_EXTENSION.contents();
905        assert!(
906            contents.contains("isGatewayAddress(resolvedBaseUrl, baseUrl)"),
907            "the asset does not verify the resolved provider endpoint is the \
908             gateway before stamping the nonce and envelope"
909        );
910    }
911
912    /// …and that gate compares URLs, not strings.
913    ///
914    /// The adversarial sibling of the test above, pinned separately because the
915    /// two failures are different sizes. Failing the check above means turns go
916    /// unattributed; failing this one means the launch nonce and the session
917    /// envelope are handed to an attacker-controlled host. A textual
918    /// `resolved.startsWith(baseUrl)` looks like it asks "is this the gateway"
919    /// and instead asks "does this begin with those characters", so a gateway at
920    /// `https://gw.example` also accepts `https://gw.example.attacker.invalid` —
921    /// a different host, a registrable lookalike, and `options.baseURL` is
922    /// user-editable config. The asset must therefore compare parsed origins,
923    /// and must not carry the prefix test that this replaced.
924    #[test]
925    fn the_opencode_plugins_gateway_check_is_a_url_boundary_not_a_string_prefix() {
926        let contents = OPENCODE_GATEWAY_EXTENSION.contents();
927        assert!(
928            !contents.contains("startsWith(baseUrl)"),
929            "the asset compares the resolved endpoint to the gateway as a string \
930             prefix; a lookalike host sharing that prefix would be handed the \
931             capture nonce and the session envelope"
932        );
933        assert!(
934            contents.contains("url.origin !== gateway.origin"),
935            "the asset does not compare parsed origins, which is what makes the \
936             host boundary — scheme, host and port — actually hold"
937        );
938        // Origin alone would let a gateway mounted at a sub-path accept a
939        // sibling of it, so the path is bounded on a separator too.
940        assert!(
941            contents.contains("url.pathname.startsWith(`${mount}/`)"),
942            "the asset does not bound the gateway's mount path on a separator"
943        );
944    }
945
946    /// Same no-default-endpoint bar as the pi asset: inert without
947    /// [`GATEWAY_URL_ENV`], with no loopback literal to fall back on.
948    #[test]
949    fn the_opencode_plugin_has_no_built_in_endpoint() {
950        let contents = OPENCODE_GATEWAY_EXTENSION.contents();
951        for literal in ["127.0.0.1:", "localhost:", "http://127.0.0.1"] {
952            assert!(
953                !contents.contains(literal),
954                "the asset hard-codes {literal:?}; it must be inert without {GATEWAY_URL_ENV}"
955            );
956        }
957    }
958
959    /// Only a harness declared as needing a bundled plugin may reach one, and
960    /// one that declares it must actually have one. Either half broken means
961    /// `plugin install` and the registry disagree about what a harness needs.
962    #[test]
963    fn artifacts_are_declared_exactly_where_the_registry_says() {
964        for harness in REGISTRY {
965            match harness.plugin() {
966                PluginDelivery::None => assert!(
967                    harness.plugin_artifacts().is_empty(),
968                    "{} needs no plugin but ships artifacts",
969                    harness.id(),
970                ),
971                PluginDelivery::BundledExtension(_) => assert!(
972                    !harness.plugin_artifacts().is_empty(),
973                    "{} declares a bundled extension with no artifacts",
974                    harness.id(),
975                ),
976                // Templates are rendered, not copied: `plugin_artifacts()` is
977                // the file-copy path and must stay empty so an installer does
978                // not write un-rendered slots into a harness.
979                PluginDelivery::HookManifestTemplates(templates) => {
980                    assert!(
981                        harness.plugin_artifacts().is_empty(),
982                        "{} must not expose templates as copyable artifacts",
983                        harness.id(),
984                    );
985                    assert!(
986                        !templates.plugin_manifest.trim().is_empty()
987                            && !templates.hooks_manifest.trim().is_empty(),
988                        "{} declares empty manifest templates",
989                        harness.id(),
990                    );
991                }
992            }
993        }
994    }
995
996    /// Two harnesses installing the same path would have the second install
997    /// overwrite the first, which no caller could detect.
998    #[test]
999    fn no_two_artifacts_install_to_the_same_path() {
1000        let home = Path::new("/home/u");
1001        let mut paths: Vec<PathBuf> = all_artifacts()
1002            .iter()
1003            .map(|artifact| artifact.install_path(home))
1004            .collect();
1005        let total = paths.len();
1006        paths.sort();
1007        paths.dedup();
1008        assert_eq!(paths.len(), total, "two artifacts share an install path");
1009    }
1010}