tapes-capture
tapes-capture is the half of capture that no harness changes. Nothing here
may learn a harness’s name — the moment it does, it has stopped being the
thing every harness shares. Where capture needs something from a harness, it
declares the HarnessSession trait and
tapes-harnesses implements it; the dependency edge
runs one way and Cargo enforces it rather than review.
cargo add tapes-captureFull API documentation is on docs.rs. This page is the contract-level reference: the envelope, its budgets and invariants, the gateway environment, and the fixture corpus that seals it all.
The envelope
Section titled “The envelope”The X-Tapes-* request-header contract carries attribution and provenance
from a capture transport — a client’s just-in-time proxy, a long-lived daemon
client, or a server-side gateway filter — into the tapes ingest server. It is
a cross-language contract: produced here in Rust, parsed by the Go
implementations in the tapes server’s ingest and gateway capture. Both halves
table-test against one shared fixture corpus (see
The fixture corpus).
The headers
Section titled “The headers”| header | required | value |
|---|---|---|
x-tapes-harness-id |
always | The harness id — the one mandatory header. |
x-tapes-harness-session-id |
when the id is not unknown |
Opaque harness-side session identifier. |
x-tapes-harness-version |
no | Harness version string. |
x-tapes-cwd |
no | Working directory, percent-encoded UTF-8. |
x-tapes-session-name |
no | User-given session name, percent-encoded, capped at 256 raw bytes. |
x-tapes-parent-harness-session-id |
no | Fork-parent’s harness session id, when the client recovered lineage. |
x-tapes-harness-metadata |
no | base64url (no padding) of a JSON object, capped at 4 KiB raw, dropped first under budget pressure. |
The harness-id vocabulary is declared in this crate: unknown, claude,
codex, codex-app, opencode, pi. The arrow points the way that first
looks backwards — the harness registry in tapes-harnesses takes its ids
from this list rather than declaring them and having the envelope import
them back. Harness ids are envelope vocabulary, they are what goes on the
wire, and that direction is what lets this crate name them without depending
on any harness.
The invariants
Section titled “The invariants”- Budget. The total
X-Tapes-*byte budget is 8 KiB (X_TAPES_TOTAL_BUDGET). Headers are inserted in a fixed order — harness id, then the plain fields, then metadata last — and the metadata header is dropped before insertion when it would push the total over, so drop semantics survive reordering. The metadata blob is separately capped at 4 KiB of raw JSON (X_TAPES_METADATA_RAW_CAP); an oversize blob drops the whole header rather than travelling truncated. - Encoding. Session name and working directory may carry arbitrary UTF-8, which RFC 7230 forbids in raw header values, so both are percent-encoded. Session names beyond 256 raw bytes are truncated to the cap, walking back to a UTF-8 boundary. Metadata is base64url without padding.
- Fail to sentinel, never fail the request. An optional field with bytes
invalid in an HTTP header is silently omitted. A failure on the required
header wipes the partial envelope and substitutes
unknown. The guarantee is thatX-Tapes-Harness-Idalways ships; capture degrades, forwarding never breaks. - The completeness rule. An inbound envelope is believed only when the
harness id is present and not
unknownand the session id is present and non-blank.TapesAttribution::from_headersis the single implementation of that rule, andinject_unattributed_envelopeuses the same answer to decide whether to preserve an inbound envelope (a self-attributing harness knows more than a failed lookup does) or replace it with the sentinel.
The completeness rule alone is not a security boundary. Trusting an inbound envelope is safe only in combination with the launch nonce below: the peer-trust ancestry walk cannot distinguish the harness from the harness’s own subprocesses, and the nonce is what proves the envelope came from the process that was launched.
This compiles and runs against the published crate:
use http::HeaderMap;use tapes_capture::envelope::{self, TapesAttribution};use tapes_capture::{provider_route, split_provider_route};
fn main() { // A request nobody could attribute still ships an envelope: // exactly `X-Tapes-Harness-Id: unknown`, and nothing else. let mut headers = HeaderMap::new(); if envelope::inject_unattributed_envelope(&mut headers).is_err() { return; // unreachable in practice: the sentinel is ASCII }
// The readback applies the producer's own completeness rule: // a sentinel envelope is not a believable identity. assert!(TapesAttribution::from_headers(&headers).is_none());
// Per-provider routing labels the path; the split is its inverse. assert_eq!(provider_route("anthropic"), "/_tapes/provider/anthropic"); assert_eq!( split_provider_route("/_tapes/provider/anthropic/v1/messages"), Some(("anthropic", "/v1/messages")) );}The full producer surface — inject_session_envelope for a resolved session,
TapesAttribution and its constructors, the header-name and cap constants —
is documented on
docs.rs.
The request-capture cap
Section titled “The request-capture cap”REQUEST_CAPTURE_CAP (32 MiB, in wire bytes) is the largest request body a
capture client should retain for capture before degrading to forward-only. It
matches the gateway side of the same contract, so client capture never
silently records less than a server-side gateway captures for the same
traffic. It is capture-only: forwarding must never gate on this value.
The gateway contract
Section titled “The gateway contract”The gateway module is the wire/environment agreement between a launching
capture client and whatever runs inside the harness on the other end. It is
two sub-protocols, and a reader who takes it for one will be surprised by the
other:
- Launch and trust.
TAPES_GATEWAY_URLnames the proxy,TAPES_GATEWAY_SCHEMAhints at which upstream schema it fronts (a display hint — a plugin must not gate the redirect on it), andTAPES_GATEWAY_NONCEcarries a per-launch secret that an installed plugin echoes back in the nonce header.nonce_matchesis the constant-time comparison that decides whether the echo counts. An installed plugin must read the nonce once at load and delete it from its process environment immediately, before any tool can run, so the harness’s own subprocesses never receive it. - Per-provider routing. A plugin can register more providers than a
single-upstream proxy can serve.
TAPES_GATEWAY_PROVIDER_ROUTESset to1tells the plugin to label each request’s path with the provider it belongs to, under/_tapes/provider/<name>;provider_routebuilds the labelled base URL andsplit_provider_routeis the proxy-side inverse. Unset, a plugin registers everything at the base URL unchanged — which is exactly what a client predating this protocol gets.
Four of the seven gateway environment variables live here, because they are
protocol. The other three (TAPES_GATEWAY_LABEL, TAPES_GATEWAY_LABEL_SUFFIX,
TAPES_GATEWAY_REMEDY) are presentation — what a product calls itself in a
harness’s status bar — and live with the artifact that reads them, in
tapes-harnesses. The full seven-variable table a launching consumer needs is
in tapes-harnesses.
Peer trust
Section titled “Peer trust”Two modules answer the question every capture client asks before it believes anything a connection tells it about itself, and neither has ever needed a harness id to answer it:
peer_pidmaps an accepted loopback connection to one of a candidate PID set, via per-OS kernel APIs.peer_trustis the ancestry walk: is the process on the other end the harness this client launched, or one of its descendants?
A descendant is deliberately as far as the walk can go — a command run by a shell tool is a descendant of the launched PID too, which is why a self-attributing harness’s envelope additionally needs the nonce echo before it is believed.
The fixture corpus
Section titled “The fixture corpus”The envelope contract is sealed by a shared fixture corpus, authored in the
tapes repository and vendored here under
crates/tapes-capture/vendor/tapes-envelope-fixtures/.
Three rules make several vendored copies one corpus:
- The corpus is vendored into every implementation of the contract, in every language, and all copies must move together from one upstream revision. A copy that moves alone is a test suite going green against bytes no other implementation has ever seen.
DIGESTmakes “the same corpus” checkable rather than asserted: sort the case files by base name, feed"<basename> <sha256>\n"for each into SHA-256, and compare. The recipe is deliberately trivial so each language restates it in a few lines rather than sharing an implementation that would itself need vendoring. A test recomputes it on everycargo test.- Cases carry a direction —
roundtrip,encode, ordecode— saying which half of the contract asserts them. A producer runs the first two and skips the third by design; a parser runs the first and third.
Consumers can table-test their own envelope composition against the same
corpus: the envelope-fixtures feature exposes envelope::fixtures, the
crate’s reader for the vendored cases. The feature is off by default and must
stay that way — the reader panics by design on a malformed corpus, which does
not belong in a production build. Enable it under [dev-dependencies]:
[dev-dependencies]tapes-capture = { version = "0.1", features = ["envelope-fixtures"] }The corpus resolves by a path relative to the crate manifest and ships inside
the packaged crate, so the reader works from a crates.io dependency and from a
git checkout alike. The crate’s docs.rs pages are built with every feature on,
so envelope::fixtures
renders there whether or not you have enabled it.
Do not change producer behaviour without updating the shared corpus in the tapes repository first, then re-vendoring. If a change makes the oracle fail, the contract conversation happens in tapes — not by editing the vendored fixtures, which the seal turns into a red test naming the file.