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}