tapes_capture/envelope.rs
1//! The capture envelope.
2//!
3//! The `X-Tapes-*` request-header contract carries attribution and provenance
4//! from a capture transport — a client's just-in-time proxy, a long-lived
5//! daemon client, or the tapes gateway filter — into the tapes ingest server. It is the narrow Rust↔Go waist: metadata, not
6//! parsing, and it rarely changes.
7//!
8//! This module is the **producer** half. It turns a resolved session identity
9//! into the on-wire header set: percent-encoding, the 256-byte session-name
10//! cap, base64url metadata, and the 8 KiB total budget. The parsers on the
11//! other side (tapes-extproc's `ParseSessionEnvelope`, the tapes ingest reader)
12//! read that header set back into an envelope. Both halves table-test against
13//! one shared fixture corpus, vendored here under
14//! `vendor/tapes-envelope-fixtures/` — see that directory's `SOURCE.md` and the
15//! oracle in `envelope_fixtures.rs`. Drift between the halves is otherwise
16//! invisible until a captured session lands mis-attributed.
17//!
18//! Extracted from a daemon client's header layer, which was the sole producer
19//! before `tapesctl` existed. The behaviour is pinned by the corpus, so every
20//! capture path emits byte-identical envelopes by construction rather than by
21//! review.
22//!
23//! What is deliberately *not* here: a consumer's bespoke auth header and its
24//! injection helper. A client fronted by an authenticating edge carries a
25//! private header so that edge admits the request; it is that consumer's
26//! channel, not part of the tapes envelope, and it stays with the consumer.
27//! The generic RFC 7230 hop-by-hop knowledge every capture proxy needs does
28//! live here — see [`HOP_BY_HOP_HEADERS`] and [`is_hop_by_hop`].
29
30use crate::session::HarnessSession;
31use base64::Engine;
32use base64::engine::general_purpose::URL_SAFE_NO_PAD;
33use http::{HeaderMap, HeaderName, HeaderValue};
34use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
35use snafu::{ResultExt, Snafu};
36use tracing::warn;
37
38/// Failure modes the envelope helpers can surface.
39#[derive(Debug, Snafu)]
40#[snafu(module, visibility(pub(crate)))]
41#[non_exhaustive]
42pub enum HeaderError {
43 /// The supplied string value is not a valid HTTP header value —
44 /// typically because it contains non-visible-ASCII bytes. Header
45 /// values must be visible ASCII per RFC 7230 §3.2.6.
46 #[snafu(display("header value is not valid HTTP-header bytes"))]
47 InvalidValue {
48 /// Underlying validation error from the `http` crate.
49 source: http::header::InvalidHeaderValue,
50 },
51}
52
53/// Hop-by-hop headers per RFC 7230 §6.1. These are scoped to a single
54/// connection and must not be forwarded across a proxy boundary. Listed
55/// in lower-case so case-insensitive comparison can use
56/// `eq_ignore_ascii_case` directly without renormalising the input.
57pub const HOP_BY_HOP_HEADERS: &[&str] = &[
58 "connection",
59 "keep-alive",
60 "proxy-authenticate",
61 "proxy-authorization",
62 "te",
63 "trailers",
64 "transfer-encoding",
65 "upgrade",
66];
67
68/// Common prefix for every capture-envelope request header.
69pub const HEADER_PREFIX: &str = "x-tapes-";
70
71// --- X-Tapes-* envelope headers -----------------------------------------
72//
73// A capture transport attaches these on every outbound LLM request. They
74// are scoped to the transport's private channel to ingest and are expected
75// to be stripped upstream before the request reaches the model provider.
76
77/// Identifies the harness — `claude`, `unknown`, or a future
78/// registered value. Required.
79pub const X_TAPES_HARNESS_ID: &str = "x-tapes-harness-id";
80
81/// Opaque harness-side session identifier. For Claude, the
82/// `sessionId` UUID from `~/.claude/sessions/<pid>.json`. Required
83/// when `harness_id != "unknown"`.
84pub const X_TAPES_HARNESS_SESSION_ID: &str = "x-tapes-harness-session-id";
85
86/// Harness version (e.g. claude `version` field). Optional.
87pub const X_TAPES_HARNESS_VERSION: &str = "x-tapes-harness-version";
88
89/// Harness working directory. Percent-encoded UTF-8 — common
90/// filesystems (macOS, Linux) allow non-ASCII path components, which
91/// RFC 7230 forbids in raw header values. Optional.
92pub const X_TAPES_CWD: &str = "x-tapes-cwd";
93
94/// User-given session name (`/name` in claude). Percent-encoded UTF-8,
95/// capped at 256 bytes raw. Optional.
96pub const X_TAPES_SESSION_NAME: &str = "x-tapes-session-name";
97
98/// Fork-parent's `harness_session_id`, when the capture client has
99/// recovered lineage from the transcript. Optional.
100pub const X_TAPES_PARENT_HARNESS_SESSION_ID: &str = "x-tapes-parent-harness-session-id";
101
102/// Base64url(no padding) of a JSON object holding the harness blob
103/// destined for `sessions.harness_metadata`. Capped at 4 KiB raw JSON;
104/// dropped first when the total `X-Tapes-*` byte budget (8 KiB) is
105/// exceeded.
106pub const X_TAPES_HARNESS_METADATA: &str = "x-tapes-harness-metadata";
107
108/// Sentinel harness-id used when the capture client couldn't attribute
109/// the request (cold race, unrecognised User-Agent, sandboxed harness).
110/// No `X-Tapes-Harness-Session-Id` is attached in this case.
111pub const HARNESS_ID_UNKNOWN: &str = "unknown";
112
113/// Harness-id attached by pi's capture extension — the one harness that stamps
114/// its own envelope from inside itself. The extension is
115/// `tapes_harnesses::plugin::PI_GATEWAY_EXTENSION`.
116pub const HARNESS_ID_PI: &str = "pi";
117
118/// Harness-id attached for Claude traffic (User-Agent `claude*`).
119pub const HARNESS_ID_CLAUDE: &str = "claude";
120
121/// Harness-id attached for Codex traffic.
122pub const HARNESS_ID_CODEX: &str = "codex";
123
124/// Harness-id for the Codex desktop app.
125///
126/// A distinct harness, not an alias of [`HARNESS_ID_CODEX`]: the app is a
127/// long-lived Codex host a consumer configures rather than launches, and its
128/// session identity arrives through lifecycle hook reports (see
129/// `tapes_harnesses::attribution::codex_app`) rather than through the peer-PID
130/// lanes.
131/// It shares Codex's wire protocol and rollout tree, so requests still carry
132/// the `thread-id`/`session-id` pair and transcripts still land under
133/// `$CODEX_HOME/sessions` — what differs is who answers "which session is
134/// this?", and keying captured sessions by a distinct id keeps that
135/// difference visible downstream.
136pub const HARNESS_ID_CODEX_APP: &str = "codex-app";
137
138/// Harness-id attached for opencode traffic.
139///
140/// opencode capture arrives with the standalone client; the constant lives
141/// here with the others so the harness registry has one place to take every id
142/// from, rather than the launch recipe spelling its own.
143pub const HARNESS_ID_OPENCODE: &str = "opencode";
144
145/// Client-side request capture cap, in wire bytes: the largest request body a
146/// capture client (tapesctl's proxy peek, any future standalone recorder)
147/// should retain for capture before degrading to forward-only.
148///
149/// 32 MiB matches the gateway side of the same contract — the Anthropic
150/// Messages request ceiling Paper commits to forwarding
151/// (`ProviderMaxRequestBytes` in tko, `MaxDecodedRequestBytes` in tapes
152/// ingest). Client capture sized below the gateway's would silently record
153/// less than the platform captures for the same traffic; sized above it, the
154/// extra bytes describe requests the provider rejects anyway. Capture-only:
155/// forwarding must never gate on this value.
156pub const REQUEST_CAPTURE_CAP: usize = 32 * 1024 * 1024;
157
158/// Maximum total budget across all `X-Tapes-*` headers.
159/// Metadata is dropped first when the budget is exceeded; the other
160/// headers are small (UUIDs and paths) and stay.
161pub const X_TAPES_TOTAL_BUDGET: usize = 8 * 1024;
162
163/// Maximum raw JSON size of the metadata blob before base64 encoding.
164/// Larger blobs cause the entire metadata header to be dropped
165/// silently — the producer enforces this locally rather than letting an
166/// oversize blob travel upstream.
167pub const X_TAPES_METADATA_RAW_CAP: usize = 4 * 1024;
168
169/// Maximum raw byte length of `X-Tapes-Session-Name` before
170/// percent-encoding. Names beyond this are truncated to the cap before
171/// encoding (silently — the cap exists to keep the header inside the
172/// total budget, not to validate user input).
173pub const X_TAPES_SESSION_NAME_CAP: usize = 256;
174
175/// Percent-encoding set for header values that may carry arbitrary
176/// UTF-8 (session name, working directory). RFC 7230 header values are
177/// visible ASCII; we escape everything outside that range and a small
178/// set of structural ASCII characters that could confuse header
179/// parsers.
180const UTF8_VALUE_ESCAPE: &AsciiSet = &CONTROLS.add(b' ').add(b'%').add(b'"').add(b'\\').add(0x7f);
181
182/// Claude Code's sub-thread header, in priority order — first present wins.
183///
184/// Claude Code stamps `x-claude-code-agent-id` on every call made from a
185/// subagent context (including its security-monitor checks) and omits it on the
186/// main thread, so presence alone is the signal.
187pub const CLAUDE_THREAD_ID_HEADERS: &[&str] = &["x-claude-code-agent-id"];
188
189/// Codex's thread id for one call: equal to [`CODEX_SESSION_ID_HEADER`] on a
190/// root turn, a distinct id on a spawned sub-thread's turn.
191///
192/// Also read — as an ordered first-present list rather than as a pair — by
193/// `tapes_harnesses::attribution::codex::session::CODEX_ROLLOUT_ID_HEADERS`, which
194/// answers a different question: *which rollout* a request belongs to. Both
195/// take their spelling from here so the two readings cannot drift apart.
196pub const CODEX_THREAD_ID_HEADER: &str = "thread-id";
197
198/// Codex's root session id, present on every Codex call.
199pub const CODEX_SESSION_ID_HEADER: &str = "session-id";
200
201/// The thread that spawned this call's thread — **one hop**, not the root.
202///
203/// Present only on a sub-thread's calls. At depth 1 it equals
204/// [`CODEX_SESSION_ID_HEADER`]; deeper, it names the immediate parent while the
205/// session header stays pinned to the root. That pairing is what makes a
206/// request self-describing enough to be joined against a rollout transcript's
207/// own `parent_thread_id` — see
208/// `tapes_harnesses::attribution::codex::request::CodexRequestIdentity`.
209pub const CODEX_PARENT_THREAD_ID_HEADER: &str = "x-codex-parent-thread-id";
210
211/// A JSON restatement of the identity headers, plus the turn id.
212///
213/// Codex sends the same session/thread/parent/subagent-kind values here that it
214/// sends as individual headers, so the blob is a *corroborating* source rather
215/// than an authoritative one: where the two disagree the request's account of
216/// itself is not trustworthy at all (see `conflicting_metadata`). It is also
217/// the only carrier of the turn id.
218///
219/// The blob additionally carries the user's prompt and other conversation
220/// content. Parsing is therefore an allowlist, exactly as it is for the
221/// desktop app's lifecycle payloads: see
222/// `tapes_harnesses::attribution::codex_app`.
223pub const CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata";
224
225/// The legacy, unstructured spelling of a sub-thread's kind.
226///
227/// Codex names the collaboration *transport* here (`collab_spawn`) while its
228/// structured metadata and the rollout transcript name the *thread source*
229/// (`thread_spawn`). The two are canonicalised to one vocabulary before
230/// comparison — see
231/// `tapes_harnesses::attribution::codex::request::canonical_subagent_kind` — so a
232/// request that says both things does not read as self-contradictory.
233pub const OPENAI_SUBAGENT_HEADER: &str = "x-openai-subagent";
234
235/// How one harness's request headers name the sub-thread a call was made from.
236///
237/// The two shapes exist because harnesses disagree about what a header's
238/// *presence* means, and a single flat list of names cannot express both.
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240#[non_exhaustive]
241pub enum HarnessThreadRule {
242 /// The harness stamps a dedicated header **only** on sub-thread calls, so
243 /// presence is the whole signal. Names are tried in order; the first one
244 /// present with a non-empty value wins.
245 FirstPresent(&'static [&'static str]),
246 /// The harness stamps both headers on **every** call, and the sub-thread
247 /// signal is their divergence rather than either one's presence.
248 ///
249 /// Both must be present and differ. Each guard earns its place:
250 ///
251 /// * An **equal** pair is a root turn. Codex sets `thread-id` ==
252 /// `session-id` there, so a flat first-present entry for `thread-id`
253 /// would stamp a thread id on every root turn — which is not cosmetic:
254 /// downstream, a non-empty thread id routes the root spine off the main
255 /// spine and silently degrades the session's derived status.
256 /// * A **lone** thread id, with no session id beside it, is not a
257 /// recognised shape for this harness at all. Treating it as a sub-thread
258 /// would risk that same misrouting on evidence the rule cannot confirm,
259 /// so it resolves to nothing instead.
260 DivergentPair {
261 /// Header carrying this call's thread id.
262 thread: &'static str,
263 /// Header carrying the root session id the thread id is compared against.
264 session: &'static str,
265 },
266}
267
268impl HarnessThreadRule {
269 /// Apply this rule to a request's headers.
270 ///
271 /// `None` means "this rule recognises nothing here" — a main-thread call,
272 /// or a request belonging to a different harness.
273 #[must_use]
274 pub fn resolve<'h>(&self, headers: &'h HeaderMap) -> Option<&'h str> {
275 match *self {
276 Self::FirstPresent(names) => names.iter().find_map(|name| header_str(headers, name)),
277 Self::DivergentPair { thread, session } => {
278 let thread_id = header_str(headers, thread)?;
279 let session_id = header_str(headers, session)?;
280 (thread_id != session_id).then_some(thread_id)
281 }
282 }
283 }
284}
285
286/// Every harness's sub-thread rule, in the order [`thread_id`] tries them.
287///
288/// This is harness knowledge, so it lives here rather than in each capture
289/// client; the rest of a client's pipeline is harness-neutral and only ever
290/// sees the resolved thread id. The table mirrors tapes-extproc's `ThreadID` —
291/// the two must agree, since extproc reads these off the wire for exactly the
292/// same purpose. Add other harnesses' rules to both as they are identified.
293///
294/// Order is precedence, and it is only observable when one request carries
295/// evidence for two harnesses at once. Claude's dedicated header is the more
296/// specific signal, so it is tried first, matching extproc.
297pub const HARNESS_THREAD_ID_RULES: &[HarnessThreadRule] = &[
298 HarnessThreadRule::FirstPresent(CLAUDE_THREAD_ID_HEADERS),
299 HarnessThreadRule::DivergentPair {
300 thread: CODEX_THREAD_ID_HEADER,
301 session: CODEX_SESSION_ID_HEADER,
302 },
303];
304
305/// A header's value as a string, treating absent, non-ASCII and empty alike as
306/// "not stated". No trimming: the comparison in
307/// [`HarnessThreadRule::DivergentPair`] is against another raw header value,
308/// and extproc compares the bytes it received.
309fn header_str<'h>(headers: &'h HeaderMap, name: &str) -> Option<&'h str> {
310 headers
311 .get(name)
312 .and_then(|value| value.to_str().ok())
313 .filter(|value| !value.is_empty())
314}
315
316/// Resolve the harness-native sub-thread id for a request.
317///
318/// Returns `None` for a main-thread call, or for a harness with no known
319/// mapping. The value is destined for the ingest turn's `meta.thread_id`; it is
320/// **not** an `X-Tapes-*` envelope header and is not stripped from the outbound
321/// request — the harness set it, and upstream may legitimately see it.
322#[must_use]
323pub fn thread_id(headers: &HeaderMap) -> Option<&str> {
324 HARNESS_THREAD_ID_RULES
325 .iter()
326 .find_map(|rule| rule.resolve(headers))
327}
328
329/// Returns true if `name` is in [`HOP_BY_HOP_HEADERS`] (case-insensitive).
330#[must_use]
331pub fn is_hop_by_hop(name: &str) -> bool {
332 HOP_BY_HOP_HEADERS
333 .iter()
334 .any(|h| h.eq_ignore_ascii_case(name))
335}
336
337/// Insert the full `X-Tapes-*` envelope for a resolved harness session.
338///
339/// Attaches `X-Tapes-Harness-Id` (the session's own
340/// [`HarnessSession::harness_id`]), `X-Tapes-Harness-Session-Id`,
341/// `X-Tapes-Harness-Version`, `X-Tapes-Cwd`, `X-Tapes-Session-Name`
342/// (percent-encoded UTF-8), optional `X-Tapes-Parent-Harness-Session-Id` when
343/// `parent_sid` is set, and `X-Tapes-Harness-Metadata` (base64url(JSON) of the
344/// session's metadata object) when the JSON fits the 4 KiB raw cap.
345///
346/// Budget enforcement: the total `X-Tapes-*` byte budget is 8 KiB.
347/// When the metadata header would push the running total over the cap,
348/// the metadata header is dropped silently and the rest proceeds. The
349/// session name is bounded to 256 bytes raw before percent-encoding so
350/// it can't dominate the budget.
351///
352/// All non-ASCII content arrives either as percent-encoded UTF-8
353/// (`X-Tapes-Session-Name`, `X-Tapes-Cwd`) or base64url
354/// (`X-Tapes-Harness-Metadata`). Any other field that happens to contain
355/// bytes invalid in an HTTP header value (CR/LF/NUL) is dropped — that
356/// header is omitted from the envelope rather than returning an error.
357/// The "mandatory headers" guarantee is satisfied by always emitting the
358/// `X-Tapes-Harness-Id` header.
359///
360/// # Errors
361///
362/// Returns [`HeaderError::InvalidValue`] only if the required harness-id
363/// value is not valid HTTP-header bytes. Unreachable in practice — the
364/// fallback path substitutes the ASCII `unknown` constant — but the
365/// signature keeps the failure visible to callers.
366pub fn inject_session_envelope(
367 headers: &mut HeaderMap,
368 session: &impl HarnessSession,
369 parent_sid: Option<&str>,
370) -> Result<(), HeaderError> {
371 inject_tapes_attribution(headers, TapesAttribution::from_session(session, parent_sid))
372}
373
374/// Insert the envelope for a request nobody could attribute.
375///
376/// Attaches only `X-Tapes-Harness-Id: unknown` — unless the inbound request
377/// already carries a complete envelope, which is preserved as-is because a
378/// harness that stamped its own identity from inside itself knows more than a
379/// failed lookup does. Any stale *partial* envelope is cleared first, so a
380/// half-stated identity never rides out alongside the sentinel.
381///
382/// The counterpart to [`inject_session_envelope`], and split from it rather
383/// than expressed as its `None` case: the two arms share no input. This one
384/// needs no session type at all, which is what lets a caller with nothing to
385/// say reach the sentinel path without naming a harness's session shape.
386///
387/// # Errors
388///
389/// Returns [`HeaderError::InvalidValue`] if the ASCII `unknown` constant is
390/// somehow not valid HTTP-header bytes. Unreachable; the signature keeps the
391/// failure visible.
392pub fn inject_unattributed_envelope(headers: &mut HeaderMap) -> Result<(), HeaderError> {
393 if has_complete_inbound_envelope(headers) {
394 return Ok(());
395 }
396 clear_tapes_headers(headers);
397 inject_tapes_attribution(headers, TapesAttribution::unknown())
398}
399
400/// Session-attribution envelope to serialize into `X-Tapes-*` headers.
401///
402/// Fields are public so a consumer can construct an attribution the named
403/// constructors below don't express — the fixture oracle does exactly that,
404/// because the shared corpus spans harnesses (`pi`) and field combinations
405/// production has no constructor for.
406pub struct TapesAttribution {
407 /// Harness identifier; [`HARNESS_ID_UNKNOWN`] selects the
408 /// single-header path.
409 pub harness_id: String,
410 /// Opaque harness-side session id.
411 pub session_id: Option<String>,
412 /// Harness version string.
413 pub version: Option<String>,
414 /// Harness working directory; percent-encoded on the wire.
415 pub cwd: Option<String>,
416 /// User-given session name; capped and percent-encoded on the wire.
417 pub name: Option<String>,
418 /// Fork-parent's harness session id.
419 pub parent_sid: Option<String>,
420 /// Free-form harness metadata; base64url(JSON) on the wire.
421 pub metadata: serde_json::Map<String, serde_json::Value>,
422}
423
424impl TapesAttribution {
425 /// The cold-race / unrecognised-harness fallback.
426 #[must_use]
427 pub fn unknown() -> Self {
428 Self {
429 harness_id: HARNESS_ID_UNKNOWN.to_owned(),
430 session_id: None,
431 version: None,
432 cwd: None,
433 name: None,
434 parent_sid: None,
435 metadata: serde_json::Map::new(),
436 }
437 }
438
439 /// Codex traffic whose session identity has not been resolved.
440 #[must_use]
441 pub fn codex() -> Self {
442 Self::codex_with_metadata(serde_json::Map::new())
443 }
444
445 /// Codex traffic with no resolved rollout, carrying request-derived
446 /// metadata (a proxy that saw useful request headers but could not
447 /// name a session still forwards what it learned).
448 #[must_use]
449 pub fn codex_with_metadata(metadata: serde_json::Map<String, serde_json::Value>) -> Self {
450 Self {
451 harness_id: HARNESS_ID_CODEX.to_owned(),
452 session_id: None,
453 version: None,
454 cwd: None,
455 name: None,
456 parent_sid: None,
457 metadata,
458 }
459 }
460
461 /// Codex traffic attributed to a resolved rollout session.
462 #[must_use]
463 pub fn codex_session(
464 session_id: &str,
465 cwd: Option<&str>,
466 cli_version: Option<&str>,
467 metadata: serde_json::Map<String, serde_json::Value>,
468 ) -> Self {
469 Self::codex_session_with_parent(session_id, None, cwd, cli_version, metadata)
470 }
471
472 /// Codex traffic attributed to a session id with optional resume/fork
473 /// parent lineage.
474 ///
475 /// `parent_sid` keeps the envelope's resume/fork meaning: it must name
476 /// a harness SESSION, never a sub-thread. A thread-spawn transcript's
477 /// `parent_thread_id` names a THREAD, and emitting it here would make a
478 /// consumer placeholder-insert a bogus session keyed by that thread id
479 /// — pass `None` for subagent rollouts.
480 #[must_use]
481 pub fn codex_session_with_parent(
482 session_id: &str,
483 parent_sid: Option<&str>,
484 cwd: Option<&str>,
485 cli_version: Option<&str>,
486 metadata: serde_json::Map<String, serde_json::Value>,
487 ) -> Self {
488 Self {
489 harness_id: HARNESS_ID_CODEX.to_owned(),
490 session_id: Some(session_id.to_owned()),
491 version: cli_version.map(str::to_owned),
492 cwd: cwd.map(str::to_owned),
493 name: None,
494 parent_sid: parent_sid.map(str::to_owned),
495 metadata,
496 }
497 }
498
499 /// Read an attribution back out of an envelope a harness stamped on
500 /// itself, for harnesses whose session identity comes from a managed
501 /// extension rather than from this crate's session watchers.
502 ///
503 /// `None` unless the headers carry a **complete** envelope: a harness id
504 /// that is present and is not the [`HARNESS_ID_UNKNOWN`] sentinel, plus a
505 /// non-blank session id. That completeness rule is the point of this
506 /// constructor. The same rule decides two things in different processes —
507 /// here, whether a capture client files a turn under an inbound envelope;
508 /// and in [`inject_unattributed_envelope`], whether the producer *preserves* an
509 /// inbound envelope instead of overwriting it with `unknown`. Two
510 /// spellings of it drift into a request whose headers say `pi` and whose
511 /// ingest row says `unknown`, so both callers come through here.
512 ///
513 /// Only the plain-text fields are read. `cwd`, session name, and metadata
514 /// are percent-encoded or base64url on the wire, and this module is the
515 /// envelope's *producer* half — the parsers live on the tapes side and
516 /// table-test against the same corpus. Decoding here would stand up a
517 /// second, drifting implementation of an encoder that is twenty lines
518 /// above, so those fields come back empty rather than guessed. Nothing is
519 /// lost today: the self-attributing harnesses stamp exactly the fields
520 /// this reads. A harness that starts sending the encoded ones wants the
521 /// decode half added here, once, not at each call site.
522 #[must_use]
523 pub fn from_headers(headers: &HeaderMap) -> Option<Self> {
524 let harness_id =
525 envelope_field(headers, X_TAPES_HARNESS_ID).filter(|id| id != HARNESS_ID_UNKNOWN)?;
526 let session_id = envelope_field(headers, X_TAPES_HARNESS_SESSION_ID)?;
527 Some(Self {
528 harness_id,
529 session_id: Some(session_id),
530 version: envelope_field(headers, X_TAPES_HARNESS_VERSION),
531 cwd: None,
532 name: None,
533 parent_sid: envelope_field(headers, X_TAPES_PARENT_HARNESS_SESSION_ID),
534 metadata: serde_json::Map::new(),
535 })
536 }
537
538 /// Traffic attributed to a resolved harness session, with optional
539 /// recovered fork-parent lineage.
540 ///
541 /// Takes [`HarnessSession`] rather than any harness's own session type.
542 /// That is the whole reason this module can live beside the wire format
543 /// instead of beside the harness registry: the projection from "what the
544 /// harness published" to "what the envelope carries" is stated once, as a
545 /// requirement, and each harness satisfies it on its own side. A new
546 /// harness reaches this constructor by implementing the trait — nothing
547 /// here learns its name.
548 #[must_use]
549 pub fn from_session(session: &impl HarnessSession, parent_sid: Option<&str>) -> Self {
550 Self {
551 harness_id: session.harness_id().to_owned(),
552 session_id: Some(session.session_id().to_owned()),
553 version: session.version().map(str::to_owned),
554 cwd: session.cwd().map(str::to_owned),
555 name: session.name().map(str::to_owned),
556 parent_sid: parent_sid.map(str::to_owned),
557 metadata: session.metadata(),
558 }
559 }
560}
561
562/// Insert the `X-Tapes-*` envelope headers for an already-resolved
563/// attribution.
564///
565/// # Errors
566///
567/// Returns [`HeaderError::InvalidValue`] if the required harness-id value
568/// is not valid HTTP-header bytes. Unreachable in practice: the failure
569/// path below wipes the partial envelope and substitutes the ASCII
570/// `unknown` constant.
571pub fn inject_tapes_attribution(
572 headers: &mut HeaderMap,
573 attribution: TapesAttribution,
574) -> Result<(), HeaderError> {
575 // Unknown-harness path: one header, no further work. Always
576 // succeeds — the constant is ASCII.
577 if attribution.harness_id == HARNESS_ID_UNKNOWN {
578 let value = HeaderValue::from_static(HARNESS_ID_UNKNOWN);
579 headers.insert(HeaderName::from_static(X_TAPES_HARNESS_ID), value);
580 return Ok(());
581 }
582
583 let mut budget = X_TAPES_TOTAL_BUDGET;
584
585 // 1. Harness-Id (REQUIRED). This is the one mandatory header on
586 // the known-harness arm. If insertion fails for any reason
587 // (unreachable today — the value comes from a fixed set of
588 // constants — but defensive against a future refactor that lets
589 // the harness-id be dynamic), wipe any other `X-Tapes-*` header
590 // we might have inserted and fall through to the unknown-harness
591 // path. Better to attribute as `unknown` than to ship an
592 // envelope that's missing the required header.
593 if let Err(err) = insert_required_ascii(
594 headers,
595 X_TAPES_HARNESS_ID,
596 &attribution.harness_id,
597 &mut budget,
598 ) {
599 warn!(
600 harness_id = %attribution.harness_id,
601 error = ?err,
602 "tapes-headers: required X-Tapes-Harness-Id insert failed; falling back to unknown",
603 );
604 clear_tapes_headers(headers);
605 let value = HeaderValue::from_static(HARNESS_ID_UNKNOWN);
606 headers.insert(HeaderName::from_static(X_TAPES_HARNESS_ID), value);
607 return Ok(());
608 }
609
610 // 2. Non-metadata headers next. Each tries to fit inside the
611 // remaining budget and is silently dropped if the value is
612 // invalid (e.g. internal CR/LF) or oversize — optional fields
613 // drop rather than failing the request.
614 if let Some(session_id) = attribution.session_id.as_deref() {
615 try_insert_string(headers, X_TAPES_HARNESS_SESSION_ID, session_id, &mut budget);
616 }
617 if let Some(v) = attribution.version.as_deref() {
618 try_insert_string(headers, X_TAPES_HARNESS_VERSION, v, &mut budget);
619 }
620 if let Some(cwd) = attribution.cwd.as_deref() {
621 // Paths on macOS/Linux can contain non-ASCII bytes (Japanese
622 // home dirs, accented characters); raw `HeaderValue::from_str`
623 // would reject them and silently drop the header. Encode the
624 // same way as the session name so the upstream sees a stable
625 // ASCII form.
626 let encoded = utf8_percent_encode(cwd, UTF8_VALUE_ESCAPE).to_string();
627 try_insert_string(headers, X_TAPES_CWD, &encoded, &mut budget);
628 }
629 if let Some(name) = attribution.name.as_deref() {
630 try_insert_session_name(headers, name, &mut budget);
631 }
632 if let Some(parent) = attribution.parent_sid.as_deref() {
633 try_insert_string(
634 headers,
635 X_TAPES_PARENT_HARNESS_SESSION_ID,
636 parent,
637 &mut budget,
638 );
639 }
640
641 // 3. Metadata blob — LOWEST priority and the first thing dropped
642 // when the envelope can't fit. By this point the non-metadata
643 // headers have already consumed their share of the budget.
644 // `try_insert_metadata` then checks the raw 4 KiB cap AND the
645 // remaining total budget BEFORE the insert. No "insert then
646 // remove" path: the encoded size is known up front from the
647 // base64url-encoded buffer length, so the drop semantics are
648 // stable regardless of any future reordering of the non-metadata
649 // headers above.
650 try_insert_metadata(headers, attribution.metadata, &mut budget);
651
652 Ok(())
653}
654
655/// Returns true when the inbound request already has the minimum
656/// envelope tapes needs to group turns under a stable session. Used for
657/// harnesses whose session identity is supplied by a managed extension
658/// rather than by this crate's session watchers.
659///
660/// Delegates to [`TapesAttribution::from_headers`] so the rule has exactly one
661/// implementation — see that constructor for why a second spelling is a bug
662/// rather than a duplication.
663#[must_use]
664pub fn has_complete_inbound_envelope(headers: &HeaderMap) -> bool {
665 TapesAttribution::from_headers(headers).is_some()
666}
667
668/// One `X-Tapes-*` header's value, trimmed, treating absent, non-ASCII,
669/// and blank alike as "not stated".
670///
671/// Distinct from [`header_str`], which deliberately does not trim: that one
672/// feeds [`HarnessThreadRule::DivergentPair`], where the comparison is against
673/// another raw header value and must see the bytes that arrived.
674fn envelope_field(headers: &HeaderMap, name: &str) -> Option<String> {
675 headers
676 .get(name)
677 .and_then(|value| value.to_str().ok())
678 .map(str::trim)
679 .filter(|value| !value.is_empty())
680 .map(str::to_owned)
681}
682
683/// Remove every `X-Tapes-*` header in `headers` in-place. Called from
684/// the required-header failure path so we don't ship a partial
685/// envelope. Implemented as collect-then-remove because `HeaderMap`'s
686/// iteration borrows immutably; the allocation is bounded by the
687/// inserted-so-far count (at most ~7 entries).
688fn clear_tapes_headers(headers: &mut HeaderMap) {
689 let to_remove: Vec<HeaderName> = headers
690 .keys()
691 .filter(|n| n.as_str().to_ascii_lowercase().starts_with(HEADER_PREFIX))
692 .cloned()
693 .collect();
694 for name in to_remove {
695 headers.remove(&name);
696 }
697}
698
699/// Insert an ASCII-only header and decrement the budget. Returns an
700/// error only if the (impossible) `from_str` fails — used for the
701/// `X-Tapes-Harness-Id` header where the value is a known constant.
702fn insert_required_ascii(
703 headers: &mut HeaderMap,
704 name: &'static str,
705 value: &str,
706 budget: &mut usize,
707) -> Result<(), HeaderError> {
708 let val = HeaderValue::from_str(value).context(header_error::InvalidValueSnafu)?;
709 let cost = name.len() + value.len();
710 *budget = budget.saturating_sub(cost);
711 headers.insert(HeaderName::from_static(name), val);
712 Ok(())
713}
714
715/// Insert `value` under `name` if (a) it fits in the remaining
716/// budget and (b) it is a valid HTTP header value. Failure on either
717/// front silently drops the header — optional fields drop rather than
718/// erroring so a malformed value never fails the whole request.
719fn try_insert_string(headers: &mut HeaderMap, name: &'static str, value: &str, budget: &mut usize) {
720 let cost = name.len() + value.len();
721 if cost > *budget {
722 return;
723 }
724 let Ok(val) = HeaderValue::from_str(value) else {
725 return;
726 };
727 *budget -= cost;
728 headers.insert(HeaderName::from_static(name), val);
729}
730
731/// Percent-encode `name` (UTF-8 → ASCII), bounded to 256 raw bytes,
732/// and insert if it fits the budget. Encoding expansion is bounded
733/// (worst case ~3× for all-multibyte input); the budget check is on
734/// the encoded length so even a heavy expansion can't overrun.
735fn try_insert_session_name(headers: &mut HeaderMap, name: &str, budget: &mut usize) {
736 let raw = if name.len() > X_TAPES_SESSION_NAME_CAP {
737 // Truncate at a UTF-8 boundary at or below the cap so the
738 // percent-encoder never sees a split codepoint.
739 let mut end = X_TAPES_SESSION_NAME_CAP;
740 while end > 0 && !name.is_char_boundary(end) {
741 end -= 1;
742 }
743 &name[..end]
744 } else {
745 name
746 };
747 let encoded = utf8_percent_encode(raw, UTF8_VALUE_ESCAPE).to_string();
748 try_insert_string(headers, X_TAPES_SESSION_NAME, &encoded, budget);
749}
750
751/// Build the metadata JSON object (structured fields the producer cares
752/// about plus the harness's verbatim `extra` map), base64url-encode
753/// it, and insert if both the raw JSON fits the 4 KiB cap and the
754/// encoded header fits the remaining total budget. Silently dropped
755/// otherwise.
756fn try_insert_metadata(
757 headers: &mut HeaderMap,
758 obj: serde_json::Map<String, serde_json::Value>,
759 budget: &mut usize,
760) {
761 if obj.is_empty() {
762 return;
763 }
764 let Ok(raw) = serde_json::to_vec(&serde_json::Value::Object(obj)) else {
765 return;
766 };
767 if raw.len() > X_TAPES_METADATA_RAW_CAP {
768 return;
769 }
770 let encoded = URL_SAFE_NO_PAD.encode(&raw);
771 try_insert_string(headers, X_TAPES_HARNESS_METADATA, &encoded, budget);
772}
773
774// The vendored shared envelope fixtures: a public reader (under the
775// `envelope-fixtures` feature) plus this crate's own producer-side oracle over
776// them. Declared as a child module of `envelope` rather than as an integration
777// test so it can construct a `TapesAttribution` field-by-field — the corpus
778// covers harnesses and field combinations the named constructors don't express.
779//
780// Compiled for this crate's own tests regardless of the feature, so the oracle
781// runs on a bare `cargo test`.
782#[cfg(any(test, feature = "envelope-fixtures"))]
783#[path = "envelope_fixtures.rs"]
784pub mod fixtures;
785
786#[cfg(test)]
787#[allow(clippy::unwrap_used, clippy::expect_used)]
788mod tests {
789 use super::*;
790 use http::HeaderValue;
791
792 #[test]
793 fn hop_by_hop_list_matches_rfc7230() {
794 assert!(
795 is_hop_by_hop("Connection"),
796 "case-insensitive match for canonical-cased header"
797 );
798 assert!(is_hop_by_hop("transfer-encoding"), "lower-case input");
799 assert!(is_hop_by_hop("PROXY-AUTHENTICATE"), "upper-case input");
800 assert!(is_hop_by_hop("Keep-Alive"));
801 assert!(is_hop_by_hop("TE"));
802 assert!(is_hop_by_hop("Trailers"));
803 assert!(is_hop_by_hop("Upgrade"));
804
805 assert!(
806 !is_hop_by_hop("Content-Length"),
807 "end-to-end header is not hop-by-hop"
808 );
809 assert!(!is_hop_by_hop("Content-Type"));
810 assert!(!is_hop_by_hop("X-Paper-Auth"));
811 }
812
813 #[test]
814 fn request_capture_cap_matches_the_gateway_contract() {
815 // 32 MiB is the provider request ceiling the gateway commits to
816 // (tko ProviderMaxRequestBytes / tapes ingest MaxDecodedRequestBytes).
817 // A consumer wiring its peek cap to this constant captures exactly
818 // what the platform captures; if the contract retunes, this pin
819 // forces the change to be deliberate on the client side too.
820 assert_eq!(REQUEST_CAPTURE_CAP, 32 * 1024 * 1024);
821 }
822
823 #[test]
824 fn is_hop_by_hop_matches_every_listed_header_in_any_case() {
825 // `hop_by_hop_list_matches_rfc7230` above spot-checks three
826 // entries; this covers the whole list, so an entry added to
827 // HOP_BY_HOP_HEADERS in a form that defeats the comparison
828 // (stray whitespace, embedded upper-case) fails here rather
829 // than leaking a connection-scoped header across the proxy
830 // boundary at runtime.
831 //
832 // The match is `eq_ignore_ascii_case` against a lower-cased
833 // table, so every case permutation of a listed name must hit.
834 for name in HOP_BY_HOP_HEADERS {
835 let upper = name.to_ascii_uppercase();
836 // Title-Case-Each-Word, the form an HTTP stack most often
837 // presents (`Transfer-Encoding`, `Proxy-Authenticate`).
838 let title: String = name
839 .split('-')
840 .map(|seg| {
841 let mut c = seg.chars();
842 match c.next() {
843 Some(first) => first.to_ascii_uppercase().to_string() + c.as_str(),
844 None => String::new(),
845 }
846 })
847 .collect::<Vec<_>>()
848 .join("-");
849
850 assert!(is_hop_by_hop(name), "lower-case `{name}` must match");
851 assert!(is_hop_by_hop(&upper), "upper-case `{upper}` must match");
852 assert!(is_hop_by_hop(&title), "title-case `{title}` must match");
853 }
854
855 // The table itself must stay lower-case: the comparison is
856 // case-insensitive, but a mixed-case entry would still be a
857 // latent trap for any caller that compares against the
858 // constant directly instead of going through `is_hop_by_hop`.
859 for name in HOP_BY_HOP_HEADERS {
860 assert_eq!(
861 *name,
862 name.to_ascii_lowercase(),
863 "HOP_BY_HOP_HEADERS entries are listed lower-case",
864 );
865 }
866 }
867
868 /// A harness session for header tests, modelled on the shape Claude
869 /// publishes — modelled fields that become metadata keys, plus a verbatim
870 /// `extra` passthrough — without importing any harness's own type. The
871 /// producer must work for whatever satisfies [`HarnessSession`]; testing
872 /// it through one harness's struct would let a change to that struct
873 /// masquerade as a change to the wire format.
874 struct SampleSession {
875 session_id: String,
876 cwd: Option<String>,
877 version: Option<String>,
878 peer_protocol: Option<i64>,
879 kind: Option<String>,
880 entrypoint: Option<String>,
881 name: Option<String>,
882 extra: serde_json::Map<String, serde_json::Value>,
883 }
884
885 impl HarnessSession for SampleSession {
886 fn harness_id(&self) -> &str {
887 HARNESS_ID_CLAUDE
888 }
889 fn session_id(&self) -> &str {
890 &self.session_id
891 }
892 fn version(&self) -> Option<&str> {
893 self.version.as_deref()
894 }
895 fn cwd(&self) -> Option<&str> {
896 self.cwd.as_deref()
897 }
898 fn name(&self) -> Option<&str> {
899 self.name.as_deref()
900 }
901 fn metadata(&self) -> serde_json::Map<String, serde_json::Value> {
902 let mut metadata = serde_json::Map::new();
903 if let Some(kind) = &self.kind {
904 metadata.insert("kind".to_owned(), serde_json::Value::String(kind.clone()));
905 }
906 if let Some(entrypoint) = &self.entrypoint {
907 metadata.insert(
908 "entrypoint".to_owned(),
909 serde_json::Value::String(entrypoint.clone()),
910 );
911 }
912 if let Some(pp) = self.peer_protocol {
913 metadata.insert(
914 "peerProtocol".to_owned(),
915 serde_json::Value::Number(pp.into()),
916 );
917 }
918 for (k, v) in &self.extra {
919 metadata.insert(k.clone(), v.clone());
920 }
921 metadata
922 }
923 }
924
925 /// Build a session with sensible defaults for header tests. Override what
926 /// the test cares about by mutation after the call.
927 fn sample_session() -> SampleSession {
928 SampleSession {
929 session_id: "eae77e15-c7d2-4883-b82e-251161f8eeb3".to_owned(),
930 cwd: Some("/Users/matt/code".to_owned()),
931 version: Some("2.1.145".to_owned()),
932 peer_protocol: Some(1),
933 kind: Some("interactive".to_owned()),
934 entrypoint: Some("cli".to_owned()),
935 name: Some("woo-names".to_owned()),
936 extra: serde_json::Map::new(),
937 }
938 }
939
940 #[test]
941 fn unattributed_envelope_is_the_unknown_sentinel_alone() {
942 // Cold-race / non-Claude callers (curl, health probes,
943 // unparsed metadata) land on the unknown-harness path:
944 // exactly one header, value `unknown`.
945 let mut headers = HeaderMap::new();
946 inject_unattributed_envelope(&mut headers).unwrap();
947
948 assert_eq!(
949 headers.get(X_TAPES_HARNESS_ID).unwrap().to_str().unwrap(),
950 HARNESS_ID_UNKNOWN
951 );
952 // The unknown-harness path attaches nothing else.
953 assert!(!headers.contains_key(X_TAPES_HARNESS_SESSION_ID));
954 assert!(!headers.contains_key(X_TAPES_CWD));
955 assert!(!headers.contains_key(X_TAPES_SESSION_NAME));
956 assert!(!headers.contains_key(X_TAPES_HARNESS_METADATA));
957 }
958
959 #[test]
960 fn unattributed_envelope_names_no_fork_parent() {
961 // The unknown path has no harness session to be a fork of, so it
962 // emits no lineage. This used to be stated by passing a `parent_sid`
963 // and requiring it to be ignored; the unattributed entry point no
964 // longer accepts one, which makes the property structural. The
965 // assertion stays because "structurally impossible" is a claim about
966 // today's signature, and the header is the thing that must be absent.
967 let mut headers = HeaderMap::new();
968 inject_unattributed_envelope(&mut headers).unwrap();
969 assert!(!headers.contains_key(X_TAPES_PARENT_HARNESS_SESSION_ID));
970 }
971
972 #[test]
973 fn inject_tapes_attribution_codex_without_session_id() {
974 let mut headers = HeaderMap::new();
975 inject_tapes_attribution(&mut headers, TapesAttribution::codex()).unwrap();
976
977 assert_eq!(
978 headers.get(X_TAPES_HARNESS_ID).unwrap().to_str().unwrap(),
979 HARNESS_ID_CODEX
980 );
981 assert!(!headers.contains_key(X_TAPES_HARNESS_SESSION_ID));
982 assert!(!headers.contains_key(X_TAPES_HARNESS_METADATA));
983 }
984
985 #[test]
986 fn inject_tapes_attribution_codex_with_session_metadata() {
987 let mut headers = HeaderMap::new();
988 let mut metadata = serde_json::Map::new();
989 metadata.insert(
990 "originator".to_owned(),
991 serde_json::Value::String("codex-tui".to_owned()),
992 );
993 inject_tapes_attribution(
994 &mut headers,
995 TapesAttribution::codex_session(
996 "019ecd8e-4281-7353-8a00-09df678443b1",
997 Some("/Users/matt/code"),
998 Some("0.139.0"),
999 metadata,
1000 ),
1001 )
1002 .unwrap();
1003
1004 assert_eq!(
1005 headers.get(X_TAPES_HARNESS_ID).unwrap().to_str().unwrap(),
1006 HARNESS_ID_CODEX
1007 );
1008 assert_eq!(
1009 headers
1010 .get(X_TAPES_HARNESS_SESSION_ID)
1011 .unwrap()
1012 .to_str()
1013 .unwrap(),
1014 "019ecd8e-4281-7353-8a00-09df678443b1"
1015 );
1016 assert_eq!(
1017 headers
1018 .get(X_TAPES_HARNESS_VERSION)
1019 .unwrap()
1020 .to_str()
1021 .unwrap(),
1022 "0.139.0"
1023 );
1024 let raw = URL_SAFE_NO_PAD
1025 .decode(
1026 headers
1027 .get(X_TAPES_HARNESS_METADATA)
1028 .unwrap()
1029 .to_str()
1030 .unwrap(),
1031 )
1032 .unwrap();
1033 let json: serde_json::Value = serde_json::from_slice(&raw).unwrap();
1034 assert_eq!(json["originator"], "codex-tui");
1035 }
1036
1037 /// Ported from the extension shim these constructors replaced: the
1038 /// metadata-only and parent-lineage constructors fill exactly the
1039 /// fields their arguments name and nothing else.
1040 #[test]
1041 fn codex_constructors_fill_only_the_fields_their_arguments_name() {
1042 let mut metadata = serde_json::Map::new();
1043 metadata.insert("k".to_owned(), serde_json::Value::String("v".to_owned()));
1044
1045 let bare = TapesAttribution::codex_with_metadata(metadata.clone());
1046 assert_eq!(bare.harness_id, HARNESS_ID_CODEX);
1047 assert!(bare.session_id.is_none());
1048 assert!(bare.parent_sid.is_none());
1049 assert_eq!(bare.metadata, metadata);
1050
1051 let keyed = TapesAttribution::codex_session_with_parent(
1052 "sid-1",
1053 Some("parent-sid"),
1054 Some("/tmp/x"),
1055 Some("0.99.0"),
1056 metadata.clone(),
1057 );
1058 assert_eq!(keyed.harness_id, HARNESS_ID_CODEX);
1059 assert_eq!(keyed.session_id.as_deref(), Some("sid-1"));
1060 assert_eq!(keyed.parent_sid.as_deref(), Some("parent-sid"));
1061 assert_eq!(keyed.cwd.as_deref(), Some("/tmp/x"));
1062 assert_eq!(keyed.version.as_deref(), Some("0.99.0"));
1063 assert_eq!(keyed.metadata, metadata);
1064
1065 // The pre-existing constructors are the no-argument special
1066 // cases of the new ones and must stay behaviorally identical.
1067 let plain = TapesAttribution::codex();
1068 assert!(plain.session_id.is_none() && plain.metadata.is_empty());
1069 let sessioned =
1070 TapesAttribution::codex_session("sid-1", Some("/tmp/x"), Some("0.99.0"), metadata);
1071 assert!(sessioned.parent_sid.is_none());
1072 assert_eq!(sessioned.session_id.as_deref(), Some("sid-1"));
1073 }
1074
1075 #[test]
1076 fn unattributed_envelope_preserves_a_complete_inbound_one() {
1077 let mut headers = HeaderMap::new();
1078 headers.insert(X_TAPES_HARNESS_ID, HeaderValue::from_static(HARNESS_ID_PI));
1079 headers.insert(
1080 X_TAPES_HARNESS_SESSION_ID,
1081 HeaderValue::from_static("paper-pi-test-session"),
1082 );
1083
1084 inject_unattributed_envelope(&mut headers).unwrap();
1085
1086 assert_eq!(
1087 headers.get(X_TAPES_HARNESS_ID).unwrap().to_str().unwrap(),
1088 HARNESS_ID_PI
1089 );
1090 assert_eq!(
1091 headers
1092 .get(X_TAPES_HARNESS_SESSION_ID)
1093 .unwrap()
1094 .to_str()
1095 .unwrap(),
1096 "paper-pi-test-session"
1097 );
1098 }
1099
1100 #[test]
1101 fn unattributed_envelope_replaces_a_partial_inbound_one() {
1102 let mut headers = HeaderMap::new();
1103 headers.insert(X_TAPES_HARNESS_ID, HeaderValue::from_static(HARNESS_ID_PI));
1104
1105 inject_unattributed_envelope(&mut headers).unwrap();
1106
1107 assert_eq!(
1108 headers.get(X_TAPES_HARNESS_ID).unwrap().to_str().unwrap(),
1109 HARNESS_ID_UNKNOWN
1110 );
1111 assert!(!headers.contains_key(X_TAPES_HARNESS_SESSION_ID));
1112 }
1113
1114 #[test]
1115 fn unattributed_envelope_clears_an_orphan_session_id() {
1116 let mut headers = HeaderMap::new();
1117 headers.insert(
1118 X_TAPES_HARNESS_SESSION_ID,
1119 HeaderValue::from_static("orphan-pi-session"),
1120 );
1121
1122 inject_unattributed_envelope(&mut headers).unwrap();
1123
1124 assert_eq!(
1125 headers.get(X_TAPES_HARNESS_ID).unwrap().to_str().unwrap(),
1126 HARNESS_ID_UNKNOWN
1127 );
1128 assert!(!headers.contains_key(X_TAPES_HARNESS_SESSION_ID));
1129 }
1130
1131 #[test]
1132 fn session_envelope_is_well_formed() {
1133 // Happy path: full session, no parent. All structured fields
1134 // populated → all corresponding headers present with the
1135 // verbatim values, plus base64url-encoded metadata.
1136 let mut headers = HeaderMap::new();
1137 let session = sample_session();
1138 inject_session_envelope(&mut headers, &session, None).unwrap();
1139
1140 assert_eq!(
1141 headers.get(X_TAPES_HARNESS_ID).unwrap().to_str().unwrap(),
1142 HARNESS_ID_CLAUDE
1143 );
1144 assert_eq!(
1145 headers
1146 .get(X_TAPES_HARNESS_SESSION_ID)
1147 .unwrap()
1148 .to_str()
1149 .unwrap(),
1150 session.session_id
1151 );
1152 assert_eq!(
1153 headers
1154 .get(X_TAPES_HARNESS_VERSION)
1155 .unwrap()
1156 .to_str()
1157 .unwrap(),
1158 "2.1.145"
1159 );
1160 assert_eq!(
1161 headers.get(X_TAPES_CWD).unwrap().to_str().unwrap(),
1162 "/Users/matt/code"
1163 );
1164 assert_eq!(
1165 headers.get(X_TAPES_SESSION_NAME).unwrap().to_str().unwrap(),
1166 "woo-names"
1167 );
1168 assert!(!headers.contains_key(X_TAPES_PARENT_HARNESS_SESSION_ID));
1169
1170 // Metadata is base64url(no-pad) of the JSON {kind,
1171 // entrypoint, peerProtocol}. Decode and check structure
1172 // instead of comparing exact bytes so the assertion isn't
1173 // brittle to JSON key ordering.
1174 let encoded = headers
1175 .get(X_TAPES_HARNESS_METADATA)
1176 .unwrap()
1177 .to_str()
1178 .unwrap();
1179 let raw = URL_SAFE_NO_PAD.decode(encoded).unwrap();
1180 let json: serde_json::Value = serde_json::from_slice(&raw).unwrap();
1181 assert_eq!(json["kind"], "interactive");
1182 assert_eq!(json["entrypoint"], "cli");
1183 assert_eq!(json["peerProtocol"], 1);
1184 }
1185
1186 #[test]
1187 fn session_envelope_attaches_parent_when_present() {
1188 let mut headers = HeaderMap::new();
1189 let session = sample_session();
1190 inject_session_envelope(&mut headers, &session, Some("parent-sid-uuid")).unwrap();
1191 assert_eq!(
1192 headers
1193 .get(X_TAPES_PARENT_HARNESS_SESSION_ID)
1194 .unwrap()
1195 .to_str()
1196 .unwrap(),
1197 "parent-sid-uuid"
1198 );
1199 }
1200
1201 #[test]
1202 fn session_envelope_omits_unset_optionals() {
1203 // None for an optional field means "harness didn't write
1204 // it". We omit the header rather than emitting a sentinel
1205 // empty value so absent and empty stay distinguishable
1206 // downstream.
1207 let mut headers = HeaderMap::new();
1208 let mut session = sample_session();
1209 session.cwd = None;
1210 session.version = None;
1211 session.name = None;
1212 // Metadata blob still has kind/entrypoint/peerProtocol so it
1213 // stays — we're testing the structured-string omissions.
1214 inject_session_envelope(&mut headers, &session, None).unwrap();
1215
1216 assert!(headers.contains_key(X_TAPES_HARNESS_ID));
1217 assert!(headers.contains_key(X_TAPES_HARNESS_SESSION_ID));
1218 assert!(!headers.contains_key(X_TAPES_CWD));
1219 assert!(!headers.contains_key(X_TAPES_HARNESS_VERSION));
1220 assert!(!headers.contains_key(X_TAPES_SESSION_NAME));
1221 }
1222
1223 #[test]
1224 fn session_envelope_percent_encodes_unicode_session_name() {
1225 // The session name is the only header value that may carry
1226 // arbitrary UTF-8 (slash-command `/name` accepts anything
1227 // the user types). Encoded form must be ASCII per RFC 7230.
1228 let mut headers = HeaderMap::new();
1229 let mut session = sample_session();
1230 // A non-ASCII codepoint and a structural ASCII byte, so we
1231 // exercise the encoder's UTF-8 and ASCII-escape paths in one
1232 // shot.
1233 session.name = Some("name with space \"quotes\" café".to_owned());
1234 inject_session_envelope(&mut headers, &session, None).unwrap();
1235
1236 let v = headers
1237 .get(X_TAPES_SESSION_NAME)
1238 .unwrap()
1239 .to_str()
1240 .expect("encoded header is ASCII");
1241 // Space and " are percent-encoded; the e-acute encodes to
1242 // its UTF-8 bytes `%C3%A9`.
1243 assert!(v.contains("%20"), "space is percent-encoded: {v}");
1244 assert!(v.contains("%22"), "quote is percent-encoded: {v}");
1245 assert!(
1246 v.contains("%C3%A9"),
1247 "non-ASCII is UTF-8 percent-encoded: {v}"
1248 );
1249 assert!(v.is_ascii(), "encoded value must be pure ASCII");
1250 }
1251
1252 #[test]
1253 fn session_envelope_truncates_session_name_at_utf8_boundary() {
1254 // Names beyond X_TAPES_SESSION_NAME_CAP (256 B raw) are
1255 // truncated to the cap before encoding. Truncation must
1256 // happen at a UTF-8 codepoint boundary so the encoder
1257 // doesn't see a split codepoint.
1258 let mut headers = HeaderMap::new();
1259 let mut session = sample_session();
1260 // 100 copies of a 3-byte codepoint (Thai `ก` = 0xE0 0xB8 0x81)
1261 // = 300 raw bytes, which exceeds the 256-byte cap. 256 mod 3
1262 // == 1, so byte 256 is mid-codepoint — the truncation logic
1263 // MUST walk back to byte 255 to land on a boundary (the start
1264 // of the 86th codepoint at offset 255, which we then drop).
1265 // After truncation: 85 codepoints survive (255 bytes raw),
1266 // each percent-encoded to `%E0%B8%81` (9 ASCII bytes), so
1267 // encoded length is 85 × 9 = 765.
1268 session.name = Some("ก".repeat(100));
1269 inject_session_envelope(&mut headers, &session, None).unwrap();
1270
1271 // Header value is ASCII (percent-encoded). The function
1272 // must not have panicked on an invalid UTF-8 slice.
1273 let v = headers.get(X_TAPES_SESSION_NAME).unwrap().to_str().unwrap();
1274 assert!(v.is_ascii(), "encoded value is ASCII");
1275 assert_eq!(
1276 v.len(),
1277 85 * 9,
1278 "85 codepoints survive truncation (raw=255 ≤ cap=256)",
1279 );
1280 }
1281
1282 #[test]
1283 fn session_envelope_drops_oversize_metadata() {
1284 // The metadata blob is dropped (silently) when the raw
1285 // JSON exceeds X_TAPES_METADATA_RAW_CAP (4 KiB). The other
1286 // X-Tapes-* headers stay; only the metadata is omitted.
1287 let mut headers = HeaderMap::new();
1288 let mut session = sample_session();
1289 // 5 KiB of opaque content via the `extra` blob — this is
1290 // exactly the failure mode the cap defends against: a
1291 // future harness key whose value is too large.
1292 let huge: String = "x".repeat(5 * 1024);
1293 session
1294 .extra
1295 .insert("hugeKnob".to_owned(), serde_json::Value::String(huge));
1296
1297 inject_session_envelope(&mut headers, &session, None).unwrap();
1298
1299 assert!(headers.contains_key(X_TAPES_HARNESS_ID));
1300 assert!(headers.contains_key(X_TAPES_HARNESS_SESSION_ID));
1301 assert!(headers.contains_key(X_TAPES_CWD));
1302 assert!(
1303 !headers.contains_key(X_TAPES_HARNESS_METADATA),
1304 "metadata blob dropped when raw JSON exceeds 4 KiB cap",
1305 );
1306 }
1307
1308 #[test]
1309 fn session_envelope_metadata_includes_extra_keys() {
1310 // Forward-compat: anything the harness writes that we don't
1311 // model explicitly flows through the `extra` map into the
1312 // metadata blob unchanged, so new keys travel upstream without
1313 // a capture-client release.
1314 let mut headers = HeaderMap::new();
1315 let mut session = sample_session();
1316 session.extra.insert(
1317 "futureKnob".to_owned(),
1318 serde_json::Value::String("preserved".to_owned()),
1319 );
1320 inject_session_envelope(&mut headers, &session, None).unwrap();
1321
1322 let encoded = headers
1323 .get(X_TAPES_HARNESS_METADATA)
1324 .unwrap()
1325 .to_str()
1326 .unwrap();
1327 let raw = URL_SAFE_NO_PAD.decode(encoded).unwrap();
1328 let json: serde_json::Value = serde_json::from_slice(&raw).unwrap();
1329 assert_eq!(json["futureKnob"], "preserved");
1330 assert_eq!(json["kind"], "interactive");
1331 }
1332
1333 #[test]
1334 fn session_envelope_metadata_empty_when_no_blob_fields() {
1335 // No kind / entrypoint / peerProtocol / extra → the
1336 // metadata blob would be an empty JSON object. We omit the
1337 // header entirely instead of attaching `{}` (the extra
1338 // round-trip costs ~70 bytes for nothing).
1339 let mut headers = HeaderMap::new();
1340 let mut session = sample_session();
1341 session.kind = None;
1342 session.entrypoint = None;
1343 session.peer_protocol = None;
1344 session.extra.clear();
1345 inject_session_envelope(&mut headers, &session, None).unwrap();
1346
1347 assert!(!headers.contains_key(X_TAPES_HARNESS_METADATA));
1348 // Other headers still present.
1349 assert!(headers.contains_key(X_TAPES_HARNESS_ID));
1350 assert!(headers.contains_key(X_TAPES_HARNESS_SESSION_ID));
1351 }
1352
1353 #[test]
1354 fn session_envelope_escapes_control_bytes_in_cwd() {
1355 // Cwd is percent-encoded UTF-8 on the wire, so CR/LF/NUL
1356 // bytes — which RFC 7230 forbids in a raw header value, and
1357 // which would let an attacker inject a second header — are
1358 // escaped to `%0A` / `%0D` / `%00`. The header lands rather
1359 // than being silently dropped, and the encoded form is safe
1360 // for any HTTP intermediary to forward verbatim.
1361 let mut headers = HeaderMap::new();
1362 let mut session = sample_session();
1363 session.cwd = Some("/Users/matt\nwith-injection: yes".to_owned());
1364 inject_session_envelope(&mut headers, &session, None).unwrap();
1365
1366 let v = headers.get(X_TAPES_CWD).unwrap().to_str().unwrap();
1367 assert!(v.contains("%0A"), "newline is percent-encoded: {v}");
1368 assert!(
1369 !v.contains('\n'),
1370 "no raw CR/LF survives into the header value: {v}",
1371 );
1372 assert!(v.is_ascii(), "encoded value must be pure ASCII");
1373 assert!(headers.contains_key(X_TAPES_HARNESS_ID));
1374 }
1375
1376 #[test]
1377 fn session_envelope_percent_encodes_unicode_cwd() {
1378 // Working directories on macOS/Linux can contain non-ASCII
1379 // bytes (Japanese home dirs, accented characters, emoji). A
1380 // raw `HeaderValue::from_str` only accepts visible ASCII, so
1381 // before this encoding was added the header was silently
1382 // dropped for these users. The encoded form is pure ASCII
1383 // and the upstream decoder percent-decodes it back.
1384 let mut headers = HeaderMap::new();
1385 let mut session = sample_session();
1386 session.cwd = Some("/Users/松本/code".to_owned());
1387 inject_session_envelope(&mut headers, &session, None).unwrap();
1388
1389 let v = headers.get(X_TAPES_CWD).unwrap().to_str().unwrap();
1390 assert!(v.is_ascii(), "encoded value must be pure ASCII");
1391 // 松 = U+677E = 0xE6 0x9D 0xBE in UTF-8; 本 = U+672C = 0xE6 0x9C 0xAC.
1392 assert!(v.contains("%E6%9D%BE"), "first codepoint encoded: {v}");
1393 assert!(v.contains("%E6%9C%AC"), "second codepoint encoded: {v}");
1394 // ASCII path segments survive verbatim.
1395 assert!(v.starts_with("/Users/"), "ASCII prefix preserved: {v}");
1396 assert!(v.ends_with("/code"), "ASCII suffix preserved: {v}");
1397 }
1398
1399 #[test]
1400 fn session_name_truncation_lands_at_or_below_cap_for_each_byte_offset() {
1401 // Pin the UTF-8 truncation behaviour across the cap
1402 // boundary. For each raw input length straddling the 256-byte
1403 // cap, the function must (a) not panic, (b) emit ASCII, and
1404 // (c) drop nothing more than needed to land on a codepoint
1405 // boundary at or below the cap.
1406 //
1407 // Two flavours of input:
1408 // * ASCII (every byte is a boundary): truncation lands
1409 // exactly on the cap when raw > 256.
1410 // * 3-byte codepoint × n (Thai `ก` = 0xE0 0xB8 0x81): 256
1411 // mod 3 == 1, so byte 256 is mid-codepoint. The truncator
1412 // walks back to byte 255 (boundary), keeping 85
1413 // codepoints when n_copies > 85.
1414
1415 // ASCII case: cap is trivially a boundary.
1416 for raw_len in [254usize, 255, 256, 257, 258, 299, 300, 301] {
1417 let ascii = "a".repeat(raw_len);
1418 let mut h = HeaderMap::new();
1419 let mut budget = X_TAPES_TOTAL_BUDGET;
1420 try_insert_session_name(&mut h, &ascii, &mut budget);
1421 let v = h.get(X_TAPES_SESSION_NAME).unwrap().to_str().unwrap();
1422 assert!(v.is_ascii(), "ascii input @ {raw_len} yields ASCII");
1423 // No escapable bytes in [a-z], so encoded == truncated raw.
1424 let expected = raw_len.min(X_TAPES_SESSION_NAME_CAP);
1425 assert_eq!(
1426 v.len(),
1427 expected,
1428 "ascii input @ {raw_len}: encoded length must equal min(raw, cap)",
1429 );
1430 }
1431
1432 // 3-byte codepoint case: walk-back kicks in once raw > cap.
1433 // n_copies × 3 raw bytes, then truncate to ≤ cap on a
1434 // boundary, then percent-encode (9 ASCII bytes per `ก`).
1435 for n_copies in [84usize, 85, 86, 87, 100] {
1436 let s = "ก".repeat(n_copies);
1437 let raw_len = s.len();
1438 assert_eq!(raw_len, n_copies * 3, "ก is 3 raw UTF-8 bytes");
1439 let mut h = HeaderMap::new();
1440 let mut budget = X_TAPES_TOTAL_BUDGET;
1441 try_insert_session_name(&mut h, &s, &mut budget);
1442 let v = h.get(X_TAPES_SESSION_NAME).unwrap().to_str().unwrap();
1443 assert!(v.is_ascii(), "utf8 input @ {raw_len} yields ASCII");
1444 // If raw ≤ cap: every codepoint survives.
1445 // If raw > cap: walk back from 256 → 255 boundary →
1446 // floor(255/3) = 85 codepoints survive.
1447 let kept = if raw_len <= X_TAPES_SESSION_NAME_CAP {
1448 n_copies
1449 } else {
1450 X_TAPES_SESSION_NAME_CAP / 3
1451 };
1452 assert_eq!(
1453 v.len(),
1454 kept * 9,
1455 "utf8 input ({n_copies} × ก, raw={raw_len}): \
1456 encoded length matches kept codepoints",
1457 );
1458 }
1459 }
1460
1461 #[test]
1462 fn clear_tapes_headers_removes_all_envelope_headers() {
1463 // When the required-header insert fails we MUST wipe the
1464 // partial envelope before falling back to `unknown`. This test
1465 // pins the helper's behaviour: every `X-Tapes-*` header (any
1466 // case) is removed; unrelated headers are kept.
1467 let mut headers = HeaderMap::new();
1468 headers.insert(
1469 HeaderName::from_static(X_TAPES_HARNESS_ID),
1470 HeaderValue::from_static("claude"),
1471 );
1472 headers.insert(
1473 HeaderName::from_static(X_TAPES_HARNESS_SESSION_ID),
1474 HeaderValue::from_static("sid"),
1475 );
1476 headers.insert(
1477 HeaderName::from_static(X_TAPES_CWD),
1478 HeaderValue::from_static("/tmp"),
1479 );
1480 headers.insert(
1481 HeaderName::from_static(X_TAPES_HARNESS_METADATA),
1482 HeaderValue::from_static("payload"),
1483 );
1484 // An unrelated header survives the wipe — clear_tapes_headers
1485 // is scoped to the X-Tapes-* prefix only.
1486 headers.insert("authorization", HeaderValue::from_static("Bearer foo"));
1487
1488 clear_tapes_headers(&mut headers);
1489
1490 assert!(!headers.contains_key(X_TAPES_HARNESS_ID));
1491 assert!(!headers.contains_key(X_TAPES_HARNESS_SESSION_ID));
1492 assert!(!headers.contains_key(X_TAPES_CWD));
1493 assert!(!headers.contains_key(X_TAPES_HARNESS_METADATA));
1494 assert!(
1495 headers.contains_key("authorization"),
1496 "non-tapes headers are preserved"
1497 );
1498 }
1499
1500 // --- sub-thread resolution ------------------------------------------
1501 //
1502 // These mirror tapes-extproc's `TestThreadID` case for case. The ids are
1503 // the same captured wire evidence its table uses, so a divergence between
1504 // the two implementations shows up as one of these failing rather than as
1505 // a mis-shaped session weeks later.
1506
1507 const CODEX_ROOT: &str = "019f863d-0cd6-7ce2-b481-20abd683a14e";
1508 const CODEX_CHILD: &str = "019f8713-2213-75e3-be33-36fd2f8dd384";
1509 const CLAUDE_AGENT: &str = "agent-0a1b2c3d";
1510
1511 fn headers_from(pairs: &[(&'static str, &str)]) -> HeaderMap {
1512 let mut headers = HeaderMap::new();
1513 for (name, value) in pairs {
1514 headers.insert(
1515 HeaderName::from_static(name),
1516 HeaderValue::from_str(value).unwrap(),
1517 );
1518 }
1519 headers
1520 }
1521
1522 #[test]
1523 fn thread_id_reads_the_claude_subagent_header() {
1524 let headers = headers_from(&[("x-claude-code-agent-id", CLAUDE_AGENT)]);
1525 assert_eq!(thread_id(&headers), Some(CLAUDE_AGENT));
1526 }
1527
1528 #[test]
1529 fn thread_id_is_absent_on_a_main_thread_call() {
1530 // Claude Code omits the header entirely on the main thread, which is
1531 // what makes its presence a reliable subagent signal.
1532 let headers = headers_from(&[("content-type", "application/json")]);
1533 assert_eq!(thread_id(&headers), None);
1534 }
1535
1536 #[test]
1537 fn a_blank_thread_id_counts_as_absent() {
1538 let headers = headers_from(&[("x-claude-code-agent-id", "")]);
1539 assert_eq!(thread_id(&headers), None);
1540 }
1541
1542 #[test]
1543 fn a_codex_child_turn_resolves_to_its_thread_id() {
1544 let headers = headers_from(&[("session-id", CODEX_ROOT), ("thread-id", CODEX_CHILD)]);
1545 assert_eq!(thread_id(&headers), Some(CODEX_CHILD));
1546 }
1547
1548 /// The root guard, and the reason Codex cannot be expressed as a flat
1549 /// first-present entry: it stamps `thread-id` on *every* call, equal to
1550 /// `session-id` on a root turn. A flat entry would stamp a thread id on
1551 /// every root turn and misroute the root spine.
1552 #[test]
1553 fn a_codex_root_turn_has_no_thread_id() {
1554 let headers = headers_from(&[("session-id", CODEX_ROOT), ("thread-id", CODEX_ROOT)]);
1555 assert_eq!(thread_id(&headers), None);
1556 }
1557
1558 #[test]
1559 fn a_codex_session_id_alone_is_a_main_thread_call() {
1560 let headers = headers_from(&[("session-id", CODEX_ROOT)]);
1561 assert_eq!(thread_id(&headers), None);
1562 }
1563
1564 /// The second guard: a `thread-id` with no `session-id` beside it is not a
1565 /// recognised Codex shape, so the pair rule declines rather than guessing
1566 /// on half the evidence.
1567 #[test]
1568 fn a_lone_thread_id_is_not_a_codex_shape() {
1569 let headers = headers_from(&[("thread-id", CODEX_CHILD)]);
1570 assert_eq!(thread_id(&headers), None);
1571 }
1572
1573 /// Rule order is precedence. Only observable when one request carries
1574 /// evidence for two harnesses at once, which is exactly when a silent
1575 /// reordering would matter.
1576 #[test]
1577 fn the_claude_rule_wins_over_a_codex_shaped_pair() {
1578 let headers = headers_from(&[
1579 ("x-claude-code-agent-id", CLAUDE_AGENT),
1580 ("session-id", CODEX_ROOT),
1581 ("thread-id", CODEX_CHILD),
1582 ]);
1583 assert_eq!(thread_id(&headers), Some(CLAUDE_AGENT));
1584 }
1585
1586 /// A complete inbound envelope reads back with its plain-text fields, and
1587 /// the encoded ones stay empty rather than half-parsed.
1588 #[test]
1589 fn from_headers_reads_a_complete_inbound_envelope() {
1590 let headers = headers_from(&[
1591 (X_TAPES_HARNESS_ID, HARNESS_ID_PI),
1592 (X_TAPES_HARNESS_SESSION_ID, "sess-1"),
1593 (X_TAPES_HARNESS_VERSION, "1.2.3"),
1594 (X_TAPES_PARENT_HARNESS_SESSION_ID, "sess-0"),
1595 // Wire-encoded fields the producer half deliberately does not
1596 // decode back; see `from_headers`.
1597 (X_TAPES_CWD, "%2Ftmp%2Fwork"),
1598 (X_TAPES_HARNESS_METADATA, "e30"),
1599 ]);
1600
1601 let attribution = TapesAttribution::from_headers(&headers).expect("envelope is complete");
1602 assert_eq!(attribution.harness_id, HARNESS_ID_PI);
1603 assert_eq!(attribution.session_id.as_deref(), Some("sess-1"));
1604 assert_eq!(attribution.version.as_deref(), Some("1.2.3"));
1605 assert_eq!(attribution.parent_sid.as_deref(), Some("sess-0"));
1606 assert_eq!(attribution.cwd, None);
1607 assert_eq!(attribution.name, None);
1608 assert!(attribution.metadata.is_empty());
1609 }
1610
1611 /// Every shape the completeness rule rejects, in one place. Each is a
1612 /// request a consumer must file as unattributed rather than under a
1613 /// half-stated identity.
1614 #[test]
1615 fn from_headers_rejects_incomplete_envelopes() {
1616 let cases: &[(&str, Vec<(&'static str, &str)>)] = &[
1617 ("no headers at all", vec![]),
1618 (
1619 "harness id but no session id",
1620 vec![(X_TAPES_HARNESS_ID, HARNESS_ID_PI)],
1621 ),
1622 (
1623 "session id but no harness id",
1624 vec![(X_TAPES_HARNESS_SESSION_ID, "sess-1")],
1625 ),
1626 (
1627 "the unknown sentinel is not an identity",
1628 vec![
1629 (X_TAPES_HARNESS_ID, HARNESS_ID_UNKNOWN),
1630 (X_TAPES_HARNESS_SESSION_ID, "sess-1"),
1631 ],
1632 ),
1633 (
1634 "a blank harness id",
1635 vec![
1636 (X_TAPES_HARNESS_ID, " "),
1637 (X_TAPES_HARNESS_SESSION_ID, "sess-1"),
1638 ],
1639 ),
1640 (
1641 "a blank session id",
1642 vec![
1643 (X_TAPES_HARNESS_ID, HARNESS_ID_PI),
1644 (X_TAPES_HARNESS_SESSION_ID, ""),
1645 ],
1646 ),
1647 ];
1648
1649 for (why, pairs) in cases {
1650 let headers = headers_from(pairs);
1651 assert!(
1652 TapesAttribution::from_headers(&headers).is_none(),
1653 "{why}: an incomplete envelope must not read back as an identity",
1654 );
1655 }
1656 }
1657
1658 /// Values are trimmed, so whitespace padding neither defeats the
1659 /// completeness rule nor rides into the attribution.
1660 #[test]
1661 fn from_headers_trims_envelope_values() {
1662 let headers = headers_from(&[
1663 (X_TAPES_HARNESS_ID, " pi "),
1664 (X_TAPES_HARNESS_SESSION_ID, " sess-1 "),
1665 ]);
1666 let attribution = TapesAttribution::from_headers(&headers).expect("padding is not absence");
1667 assert_eq!(attribution.harness_id, HARNESS_ID_PI);
1668 assert_eq!(attribution.session_id.as_deref(), Some("sess-1"));
1669 }
1670
1671 /// The bug this hoist exists to prevent: the rule that decides whether the
1672 /// producer PRESERVES an inbound envelope and the rule that decides
1673 /// whether a consumer FILES a turn under one must be the same rule. If
1674 /// they ever diverge, a request's headers say `pi` while its ingest row
1675 /// says `unknown`.
1676 ///
1677 /// Asserted as agreement across the whole case table rather than by
1678 /// inspecting either implementation, so a future re-spelling of one side
1679 /// fails here.
1680 #[test]
1681 fn envelope_preservation_and_readback_apply_one_rule() {
1682 let cases: &[Vec<(&'static str, &str)>] = &[
1683 vec![],
1684 vec![(X_TAPES_HARNESS_ID, HARNESS_ID_PI)],
1685 vec![(X_TAPES_HARNESS_SESSION_ID, "sess-1")],
1686 vec![
1687 (X_TAPES_HARNESS_ID, HARNESS_ID_UNKNOWN),
1688 (X_TAPES_HARNESS_SESSION_ID, "sess-1"),
1689 ],
1690 vec![
1691 (X_TAPES_HARNESS_ID, HARNESS_ID_PI),
1692 (X_TAPES_HARNESS_SESSION_ID, ""),
1693 ],
1694 vec![
1695 (X_TAPES_HARNESS_ID, HARNESS_ID_PI),
1696 (X_TAPES_HARNESS_SESSION_ID, "sess-1"),
1697 ],
1698 ];
1699
1700 for pairs in cases {
1701 let mut headers = headers_from(pairs);
1702 let readable = TapesAttribution::from_headers(&headers).is_some();
1703 assert_eq!(
1704 has_complete_inbound_envelope(&headers),
1705 readable,
1706 "the predicate and the reader disagree about {pairs:?}",
1707 );
1708
1709 // And the producer must act on that same answer: a complete
1710 // envelope survives an unattributed injection untouched, an
1711 // incomplete one is replaced by the `unknown` sentinel.
1712 let before = headers.clone();
1713 inject_unattributed_envelope(&mut headers).unwrap();
1714 if readable {
1715 assert_eq!(
1716 headers.get(X_TAPES_HARNESS_ID),
1717 before.get(X_TAPES_HARNESS_ID),
1718 "a complete envelope was overwritten: {pairs:?}",
1719 );
1720 } else {
1721 assert_eq!(
1722 headers.get(X_TAPES_HARNESS_ID).unwrap(),
1723 HARNESS_ID_UNKNOWN,
1724 "an incomplete envelope was not replaced with the sentinel: {pairs:?}",
1725 );
1726 }
1727 }
1728 }
1729
1730 /// Exactly one rule is declared as a divergent pair, and it is the Codex
1731 /// one. The harness-side rollout-id lookup reads the same two spellings to
1732 /// answer a different question, and asserts agreement from its side —
1733 /// which is the only side that can, now that this module cannot name a
1734 /// harness's attribution lane.
1735 #[test]
1736 fn exactly_one_rule_is_a_divergent_pair() {
1737 let pairs: Vec<(&str, &str)> = HARNESS_THREAD_ID_RULES
1738 .iter()
1739 .filter_map(|rule| match rule {
1740 HarnessThreadRule::DivergentPair { thread, session } => Some((*thread, *session)),
1741 HarnessThreadRule::FirstPresent(_) => None,
1742 })
1743 .collect();
1744 assert_eq!(
1745 pairs,
1746 vec![(CODEX_THREAD_ID_HEADER, CODEX_SESSION_ID_HEADER)],
1747 );
1748 }
1749}