The spycatcher_harness crate exposes two primary entry points for harness
lifecycle management.
Starting the harness
Call start_harness with a HarnessConfig to validate configuration and
prepare the harness for operation. In record mode, startup now binds a real
local HTTP listener and returns the actual bound socket address in
RunningHarness.addr. In replay mode, startup opens the configured cassette
file read-only, validates its format_version, and returns a running harness
that serves the recorded interactions described later in this guide. In verify
mode, library startup returns HarnessError::ModeNotYetImplemented; verify
mode is available through the CLI for cassette and configuration checks.
use spycatcher_harness::{start_harness, HarnessConfig};
# async fn example() -> spycatcher_harness::HarnessResult<()> {
# Ok(())
# }
Configuration
HarnessConfig controls all aspects of harness behaviour. Use struct update
syntax to override specific fields:
use spycatcher_harness::HarnessConfig;
use spycatcher_harness::config::ListenAddr;
let cfg = HarnessConfig {
listen: ListenAddr::from(
"127.0.0.1:9090".parse::<std::net::SocketAddr>().unwrap()
),
..HarnessConfig::default()
};
Configuration fields:
listen— address and port the harness listens on (default:127.0.0.1:8787).mode—Mode::RecordorMode::Replay(default:Replay).protocol— protocol to expose (default:OpenAiChatCompletions).match_mode— replay matching strategy:SequentialStrict(default) orKeyed.cassette_dir— directory containing cassette files (default:fixtures/llm).cassette_name— name of the cassette file (default:default).upstream— upstream provider config (required for record mode).redaction— header redaction rules (default: dropsauthorization). SupplyRedactionConfig { drop_headers: vec![] }to disable all redaction, or extend the default with:
RedactionConfig {
drop_headers: vec!["authorization".to_owned(), "x-my-secret".to_owned()],
}
replay— timing controls for replay mode.localization— locale settings.
Localizing library messages
The library embeds its own Fluent Translation List resources under i18n/, but
does not create a process-wide language loader or detect the process locale.
Applications own language negotiation and inject their configured loader when
they need localized library text:
use i18n_embed::fluent::FluentLanguageLoader;
use spycatcher_harness::HarnessError;
use spycatcher_harness::i18n::{HarnessLocalizations, localize_harness_error};
let fallback = "en-US"
.parse::<i18n_embed::unic_langid::LanguageIdentifier>()
.unwrap();
let loader = FluentLanguageLoader::new("spycatcher-harness", fallback.clone());
i18n_embed::select(&loader, &HarnessLocalizations, &[fallback]).unwrap();
let error = HarnessError::InvalidConfig {
message: "missing upstream".to_owned(),
};
let rendered = localize_harness_error(&loader, &error);
assert_eq!(
rendered,
"invalid configuration: \u{2068}missing upstream\u{2069}"
);
Successful rendering preserves Fluent's bidirectional isolation marks around
dynamic values. If the supplied loader has not loaded the library resources,
rendering falls back to the existing non-localized HarnessError display text.
CLI locale selection and localized clap help remain separate
application-level responsibilities. The spycatcher-harness binary builds one
language loader at startup from its layered localization configuration and uses
the embedded English catalogue as the default fallback.
Security considerations
Fluent argument substitution is named and does not re-parse argument values as Fluent Translation List syntax, so user-supplied strings cannot escape the template or invoke arbitrary selectors.
HarnessError::Io includes the underlying [std::io::Error] text, which may
carry sensitive path information. Callers should treat that text accordingly
and avoid surfacing it in user-visible output without sanitization.
Replay startup expectations:
- The cassette file must already exist at
cassette_dir/cassette_name. - The file name is exactly
cassette_name; the harness does not append an implicit.jsonsuffix. - The stored cassette must use the currently supported
format_version.
Record-mode startup expectations:
upstreammust be configured.- The listener is bound during
start_harness, soRunningHarness.addrmay differ from the requested address when the configured port is0. shutdown()gracefully stops the bound record-mode server.
Record mode proxying
Record mode supports OpenAI-compatible chat completions requests:
- The harness accepts
POST /v1/chat/completions. - Requests with
streamunset orfalseare proxied upstream, returned to the client, and appended to the configured cassette. - Requests with
stream: trueare proxied as Server-Sent Events (SSE), returned to the client as upstream bytes arrive, and appended to the configured cassette after the stream completes successfully.
Upstream authentication and enrichment:
- The bearer token is sourced from
upstream.api_key_envat request time. upstream.extra_headersare added only to the outbound upstream request.- The recorded request in the cassette reflects the client-visible inbound request after header selection and redaction, not the enriched outbound proxy request.
Header capture and redaction:
- Request capture drops hop-by-hop and framing headers.
- Persisted request headers additionally exclude
host,content-length, andaccept-encoding. - Persisted response headers exclude hop-by-hop headers and
content-length. redaction.drop_headersremoves matching header names case-insensitively immediately before persistence, preserving the observed order and duplicates of the retained headers.
RedactionConfig is secure by default: authorization is in drop_headers
unless an explicit RedactionConfig is provided. To retain authorization in
the cassette, supply RedactionConfig { drop_headers: vec![] }.
Persisted response contract:
- Persisted response headers exclude hop-by-hop headers and
content-length. Redaction viaredaction.drop_headersremoves matching header names case-insensitively immediately before persistence, preserving the observed order and duplicates of the retained headers. - Non-stream upstream response bodies are stored byte-for-byte in the cassette.
parsed_jsonis populated only when the response body decodes and parses as valid JSON; otherwiseparsed_jsonis left empty so consumers know what replay can reconstruct.- Streamed upstream responses are stored as
kind: "stream"responses with selected response headers, parsed stream events, raw transcript bytes, and timing metadata. - SSE comment lines such as
: OPENROUTER PROCESSINGare recorded as comment events.data:frames are recorded with their raw payload text andparsed_jsonwhen the payload is valid JSON. Terminaldata: [DONE]markers are retained as data events with no parsed JSON. - If a streamed upstream response contains invalid UTF-8 or ends with an incomplete SSE event, the harness still returns any bytes already received from upstream to the client, but it does not append a successful cassette entry for that malformed stream.
Replay behaviour for chat completions:
- Replay mode accepts
POST /v1/chat/completionsagainst an existing cassette. - A matching request returns the recorded status, persisted selected response headers, and response body from the cassette.
- Non-stream responses replay the recorded body bytes.
- Stream responses replay the recorded parsed SSE events as canonical SSE
frames. Recorded comment events are emitted as
: ...frames, recorded data events are emitted asdata: ...frames, and event order is preserved. - If a stream cassette omits
content-type, replay setstext/event-stream. - Replay mode does not require upstream configuration or an upstream API key,
and it constructs no outbound upstream client. If
upstreamis present in a replay configuration, it is ignored by the replay request path. - Mismatched requests return HTTP
409 Conflictwith a JSONrequest_mismatchdiagnostic containing the position, expected hash, observed hash, and diff summary. - Replay rejects malformed or non-JSON chat completions request bodies with
HTTP
400 Bad Requestand a JSONmalformed_jsonerror before matching. This prevents different malformed byte sequences from sharing the same body-less replay hash. - A request with
stream: truemust still match a recorded request whose canonical body includes the same streaming shape. If no interaction matches, replay returns the normal HTTP409 Conflictrequest-mismatch diagnostic. - If a
stream: truerequest matches a manually authored non-stream response, replay returns HTTP501 Not Implementedwithstream_cassette_required. - Replay currently serializes parsed stream events rather than the raw
raw_transcriptbytes. Byte-faithful SSE replay remains deferred to roadmap task2.1.3.
Replay matching modes
The harness supports two matching modes for replay:
- Sequential strict mode (default): requests must arrive in the exact recorded order. Each incoming request is expected to match the next interaction in the cassette. Mismatches fail fast with an HTTP 409 response containing:
- The expected interaction ID (zero-based index).
- The expected and observed request hashes.
- A field-level diff summary comparing the canonical request JSON values.
This mode maximizes repeatability and debugging speed for deterministic, single-threaded agent loops.
- Keyed mode: requests are matched by their canonical request hash. The engine consumes the next unused interaction with the matching hash, allowing requests to arrive out of order. When multiple interactions share the same hash, they are consumed in recorded order.
This mode supports limited reordering and concurrent requests, at the cost of less precise failure locations.
Configure the matching mode using the match_mode field:
use spycatcher_harness::HarnessConfig;
use spycatcher_harness::config::MatchMode;
let cfg = HarnessConfig {
match_mode: MatchMode::Keyed,
..HarnessConfig::default()
};
The cassette module also exposes
canonicalize_events(events, StreamCanonicalPolicy::ignore_comments()) for
tools that compare recorded stream-event sequences while ignoring comment-only
drift. This is a library helper for cassette consumers and future verification
work; it is not a replay CLI configuration option.
When a mismatch occurs in sequential strict mode, the diagnostic response includes a field-level diff showing the differences between the expected and observed canonical requests. The diff format uses:
added: <path>: <value>— field present in observed but not expected.removed: <path>— field present in expected but not observed.changed: <path>: <expected_value> -> <observed_value>— differing values.
Paths use dotted notation for nested objects (e.g.,
canonical_body.metadata.run_id) and bracket notation for array elements (e.g.,
messages[0].role).
Canonical request hashing
The cassette module exposes deterministic canonicalization helpers for replay matching and diagnostics:
use serde_json::json;
use spycatcher_harness::cassette::{
IgnorePathConfig, RecordedRequest, canonicalize, stable_hash,
};
# fn example() -> Result<(), spycatcher_harness::cassette::CanonicalError> {
let request = RecordedRequest {
method: "post".to_owned(),
path: "/v1/chat/completions".to_owned(),
query: "b=2&a=1".to_owned(),
headers: Vec::new(),
body: br#"{"metadata":{"run_id":"42"},"model":"gpt-test"}"#.to_vec(),
parsed_json: Some(json!({
"metadata": {"run_id": "42"},
"model": "gpt-test"
})),
canonical_request: None,
stable_hash: None,
};
let ignore_paths = IgnorePathConfig {
ignored_body_paths: vec!["/metadata/run_id".to_owned()],
};
let canonical = canonicalize(&request, &ignore_paths)?;
let hash = stable_hash(&canonical);
assert_eq!(canonical.method, "POST");
assert_eq!(canonical.canonical_query, "a=1&b=2");
assert_eq!(hash.len(), 64);
# Ok(())
# }
Error handling
All public API functions return HarnessResult<T>, which is an alias for
Result<T, HarnessError>. The HarnessError enum provides typed variants for
each failure mode:
InvalidConfig— configuration validation failed.CassetteNotFound— the named cassette does not exist.InvalidCassette— the cassette JSON is malformed or missing required fields.UnsupportedCassetteFormatVersion— cassette schema version is not supported during cassette loading or startup (includes replay and verify modes).ModeNotYetImplemented— the selected operating mode is configured but does not yet start a running harness.RequestMismatch— a replayed request did not match the expected interaction.UpstreamRequestFailed— a request to the upstream provider failed.Io— an I/O operation failed.
Shutdown
Call shutdown() on a RunningHarness to tear down the harness gracefully:
# use spycatcher_harness::{start_harness, HarnessConfig};
# async fn example() -> spycatcher_harness::HarnessResult<()> {
# let cfg = HarnessConfig::default();
# let harness = start_harness(cfg).await?;
harness.shutdown().await?;
# Ok(())
# }