You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Adds transport::reqwest_client_builder(): a preconfigured reqwest::ClientBuilder for code that needs a plain reqwest::Client and cannot go through HttpClient (progenitor-generated clients, hand-rolled streaming or multipart uploads). It applies the process-wide user-agent and default headers, a 5s connect timeout, and a 30s idle read timeout, so outbound policy is defined once in the engine instead of per call site. HttpClient now builds on the same factory.
Adds opt-in client attribution (CliConfig::with_client_attribution(AttributionConfig::new())) so a CLI can tell the services it calls what kind of caller it is, without any telemetry channel:
User-Agent tokens: mode/<agent|ci|interactive|script>, plus agent/<slug> when an AI harness is detected.
A correlation header (default x-client-session, configurable) carrying a salted SHA-256 prefix of the harness session id. The raw id never leaves the process, and nothing is sent when no harness session id exists (no persistent identifier, nothing written to disk).
<APP_ID>_NO_SESSION_ID=1 drops the header; AttributionConfig::without_session_id() disables it for a CLI.
Detection uses the is-ai-agent crate (zero dependencies, MIT/Apache). It is cooperative and heuristic, and docs/attribution.md says so.
The user-agent and the new default headers share one lock (ClientIdentity) rather than adding a third process-global.
Off by default, so existing consumers' user-agents are unchanged. Full description of what is and is not sent: cli-engine/docs/attribution.md.
Identity publishing semantics (refined during review)
The user-agent and default headers are written (set_client_identity) and read (client_identity_snapshot) under a single lock acquisition, so a client never pairs one publish's user-agent with another's headers.
set_default_headers drops entries with an invalid header name or value at publish time (logging the name), so the factory and HttpClient only ever see sendable headers.
Header precedence on an HttpClient request is applied once, on the built request: the client's own default headers replace a same-named header the request set by default (e.g. a vendor Content-Type), exactly once; process-wide defaults rank lowest and only fill in headers still absent, so they can never duplicate or override anything. Names are case-insensitive. Defaults are also visible in the --debug transport trace.
HttpClient captures the process identity once, when its builder is created; its base reqwest::Client is built from a timeout-only builder and never re-reads process identity.
Because identity is captured when a client (or reqwest_client_builder()) is created, create clients inside command handlers, which run after execute* has published, not during module registration. Documented in docs/attribution.md and on the relevant APIs.
Identity is published by the execute* entrypoints after argv0 resolution, so an argv0 personality (an independent Cli with its own config) publishes its own identity rather than the dispatcher's.
Cli::run intentionally does not publish, so running a Cli (as tests do, concurrently) never mutates process-wide state. This is documented on Cli::run and in docs/attribution.md.
Behavior changes to review
HttpClient previously had no timeouts. It now has a 5s connect timeout and a 30s idle read timeout (per read, not per request). No total-request timeout is applied, so large streaming transfers that keep making progress are unaffected, but a server that takes more than 30s to send the first response byte now fails where it previously waited.
sha2 is no longer an optional dependency (attribution needs it unconditionally); it is removed from the pkce-auth feature list.
New dependency: is-ai-agent 0.6.
Out of scope / follow-ups
The default session header name (x-client-session) is a placeholder. It is trivially changed in one constant; I'd like reviewer opinion before this ships.
traceparent forwarding is intentionally not included.
Attribution headers are not applied to the engine's OAuth token requests (the user-agent tokens are).
Test plan
cargo fmt --all --check
cargo clippy --all-targets -- -D warnings (default features and --features pkce-auth)
cargo test --all-targets (default features and --features pkce-auth)
factory: UA reaches the wire, caller override wins, a stalled server hits the read timeout instead of hanging, default headers reach the wire, a caller's later .default_headers(..)adds to (does not replace) the process defaults, an invalid default header is skipped rather than fatal, and HttpClient merges process defaults under its own headers (client wins on a clash).
attribution: harness detection, hashed (never raw) session id, hash stability and per-app salting, mode precedence, CI=false/0/off handling, the opt-out env var and its name derivation, configurable/invalid header names.
Cli::client_identity composes the base user-agent (including an explicit override) with attribution.
identity pair is never torn under concurrent publishes; invalid default headers are dropped at publish time and do not fail HttpClient requests; an argv0 personality publishes its own identity on the execute path; Cli::run leaves the identity untouched.
Manual verification
This was validated end to end against a downstream consumer CLI (gddy) by pointing it at this branch with a local [patch.crates-io] override and running the real binary against a local capture server.
Setup:
# In a consumer CLI that opts in via `.with_client_attribution(AttributionConfig::new())`:# Cargo.toml# [patch.crates-io]# cli-engine = { path = "../cli-engine/cli-engine" }
cargo build
# Point the CLI at a local HTTP server that logs `user-agent` and `x-client-session`.
Test WITHOUT the change (baseline):
# On main: any request carries only `<name>/<version>`; no mode/agent tokens, no session header.
Test WITH the change:
CLAUDECODE=1 CLAUDE_CODE_SESSION_ID=sess-secret-123 <cli><any command that makes a request># Expected: user-agent `<name>/<version> mode/agent agent/claude-code`# x-client-session: 16 hex chars (the raw id does not appear anywhere)<cli><same command># no harness env, non-TTY# Expected: user-agent `<name>/<version> mode/script`, no session header
CLAUDECODE=1 CLAUDE_CODE_SESSION_ID=sess-secret-123 <APP_ID>_NO_SESSION_ID=1 <cli><same command># Expected: agent tokens present, no session header
Cleanup:
# Remove the [patch.crates-io] block from the consumer's Cargo.toml.
…ibution
Add `transport::reqwest_client_builder()`, a preconfigured
`reqwest::ClientBuilder` for code that needs a plain `reqwest::Client`
(progenitor-generated clients, hand-rolled streaming/multipart) and cannot
go through `HttpClient`. It applies the process-wide user-agent and default
headers, a 5s connect timeout, and a 30s idle read timeout, so outbound
policy is defined once in the engine instead of per call site. `HttpClient`
now builds on the same factory.
Add opt-in client attribution (`CliConfig::with_client_attribution`) so a CLI
can tell the services it calls what kind of caller it is without any
telemetry channel: `mode/<agent|ci|interactive|script>` and `agent/<slug>`
User-Agent tokens, plus a correlation header carrying a salted SHA-256 prefix
of the harness session id (never the raw id). Detection uses the
`is-ai-agent` crate. `<APP_ID>_NO_SESSION_ID=1` drops the header. Off by
default. See docs/attribution.md.
The process-wide user-agent and the new default headers now share one lock
(`ClientIdentity`) rather than adding a third global. New public API:
`default_user_agent`, `set_default_headers`, `default_headers`,
`DEFAULT_CONNECT_TIMEOUT`, `DEFAULT_READ_TIMEOUT`, `AttributionConfig`.
`sha2` is no longer optional.
Behavior change: `HttpClient` previously had no timeouts; it now has the
connect/idle-read timeouts above. No total-request timeout is applied, so
long streaming transfers are unaffected.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…headers, honor argv0 personalities
Address review feedback on the client attribution change:
- The user-agent and default headers were stored under one lock but written
and read in separate acquisitions, so a concurrent reader could pair one
publish's user-agent with another's headers. Add an atomic
`set_client_identity` and a single-lock `client_identity_snapshot`, and use
the snapshot in `reqwest_client_builder` and `HttpClientBuilder::new`.
- `set_default_headers` accepted arbitrary strings, and `HttpClient` merged
them raw into every request, so one invalid entry could fail every request
at construction time. Entries with an invalid name or value are now dropped
(and their names logged) when published, so every consumer sees only
sendable headers.
- An argv0 personality runs an independent `Cli` with its own config, but only
the dispatcher's identity was published. The execute path now publishes
after argv0 resolution and forwards the flag into the personality's run, so
the CLI that actually executes publishes its own identity. `Cli::run` still
does not publish (tests call it concurrently); this is now documented on
`Cli::run` and in docs/attribution.md.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…nsensitive header override
`HttpClientBuilder::new` captured the process identity, but `build()` created
the base `reqwest::Client` from `reqwest_client_builder()`, which took a second
snapshot. If another identity was published in between, the base client could
carry the newer snapshot's uniquely named default headers while requests added
the older snapshot's, leaking another CLI's correlation header. `HttpClient`
applies its user-agent and default headers per request, so its base client now
comes from a timeout-only builder that never reads process identity.
Header names are case-insensitive, but process defaults were merged with
client headers by exact key, so a client's `X-Client-Session` was sent
alongside a default `x-client-session` instead of replacing it. Default header
names are now lowercased when published and the merge compares them
case-insensitively.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Process default headers duplicate request-specific headers
cli-engine/src/transport/client/mod.rs:387
Process defaults are merged into the map that each request adds after its request-specific headers (for example, Content-Type is set before the loop in methods.rs:548-551). RequestBuilder::header appends, so a valid process default such as content-type or user-agent produces duplicate values rather than being overridden by the client/request, contrary to this API's “client wins” contract. Apply the captured process map as actual reqwest client defaults, or skip defaults for names already present on the request.
Clients created before attribution publication retain stale process identity
cli-engine/docs/attribution.md:50
This says attribution is applied to every HttpClient, but HttpClientBuilder::new snapshots identity immediately, while Cli::new invokes module registration before any execute* entrypoint publishes attribution. A client created or captured during module registration therefore permanently retains the previous process identity. Document that both client builders must be called after publication (or change the lifecycle) so consumers do not silently miss attribution.
…ument client-creation timing
Process-wide default headers were appended to every HttpClient request after
its request-specific headers, and `RequestBuilder::header` appends, so a valid
process default such as `content-type` or `user-agent` produced duplicate
header lines instead of losing to the request's own value.
HttpClient now applies defaults once, on the built request:
- the client's own default headers replace a same-named header the request set
by default (the legacy way to opt into a vendor `Content-Type`), once rather
than alongside it;
- process-wide defaults rank lowest and only fill in headers still absent, so
they can never duplicate or override anything.
Applying them on the built request also keeps them visible in the
`--debug transport` trace.
Also document that `HttpClientBuilder::new` and `reqwest_client_builder` capture
the process identity when called, so clients must be created inside command
handlers (after `execute*` publishes), not during module registration.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Addressing the two "Previously missed" findings from the latest Copilot review (they were bundled in the review body, so there were no threads to resolve). Fixed in 25f43d3.
1. Process default headers duplicated request-specific headers (mod.rs). Confirmed: I reproduced it first, and a process default content-type/user-agent put two lines of each on the wire. Defaults are now applied once, on the built request, instead of being appended on the builder. The reviewer's suggested rule ("request always wins") had to be refined, though: an existing test, http_client_default_headers_can_override_json_content_type_preserves_legacy, documents that a client's own default Content-Type is meant to override the JSON one. So the hierarchy is: the client's own default headers replace a same-named request header (once, not alongside it), while process-wide defaults rank lowest and only fill in headers that are absent. New tests pin both halves, plus the Content-Type override exactly once.
2. Clients created before attribution publication keep stale identity (attribution.md). Correct, and a documentation gap rather than a code defect: HttpClientBuilder::new and reqwest_client_builder have always captured the identity when called (the same was true of the user-agent before this PR). Documented that clients must be created inside command handlers, not during module registration, in docs/attribution.md, on HttpClientBuilder::new, reqwest_client_builder, and with_client_attribution.
Every execute* call replaces the entire header map with attribution headers (or an empty map), so headers configured through the new public set_default_headers API during normal startup are silently discarded before command handlers create clients. Preserve caller-configured defaults as a separate layer and merge the per-execution attribution headers into them, while still removing attribution left by a previous execution.
Apply process defaults before identity user-agent
cli-engine/src/transport/client/factory.rs:48
The factory installs the user-agent before the process default header map, so a user-agent entry accepted by set_default_headers replaces the identity user-agent. This contradicts HttpClient, where process defaults rank below the request-owned user-agent, and makes default_user_agent() differ from what goes over the wire. Apply the process header map first so the identity user-agent wins; callers can still override it later on the returned builder.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
transport::reqwest_client_builder(): a preconfiguredreqwest::ClientBuilderfor code that needs a plainreqwest::Clientand cannot go throughHttpClient(progenitor-generated clients, hand-rolled streaming or multipart uploads). It applies the process-wide user-agent and default headers, a 5s connect timeout, and a 30s idle read timeout, so outbound policy is defined once in the engine instead of per call site.HttpClientnow builds on the same factory.CliConfig::with_client_attribution(AttributionConfig::new())) so a CLI can tell the services it calls what kind of caller it is, without any telemetry channel:User-Agenttokens:mode/<agent|ci|interactive|script>, plusagent/<slug>when an AI harness is detected.x-client-session, configurable) carrying a salted SHA-256 prefix of the harness session id. The raw id never leaves the process, and nothing is sent when no harness session id exists (no persistent identifier, nothing written to disk).<APP_ID>_NO_SESSION_ID=1drops the header;AttributionConfig::without_session_id()disables it for a CLI.is-ai-agentcrate (zero dependencies, MIT/Apache). It is cooperative and heuristic, anddocs/attribution.mdsays so.ClientIdentity) rather than adding a third process-global.cli-engine/docs/attribution.md.New public API
transport::{reqwest_client_builder, default_user_agent, set_default_headers, default_headers, DEFAULT_CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT, AttributionConfig},CliConfig::with_client_attribution,CliConfig::attribution.Identity publishing semantics (refined during review)
set_client_identity) and read (client_identity_snapshot) under a single lock acquisition, so a client never pairs one publish's user-agent with another's headers.set_default_headersdrops entries with an invalid header name or value at publish time (logging the name), so the factory andHttpClientonly ever see sendable headers.HttpClientrequest is applied once, on the built request: the client's own default headers replace a same-named header the request set by default (e.g. a vendorContent-Type), exactly once; process-wide defaults rank lowest and only fill in headers still absent, so they can never duplicate or override anything. Names are case-insensitive. Defaults are also visible in the--debug transporttrace.HttpClientcaptures the process identity once, when its builder is created; its basereqwest::Clientis built from a timeout-only builder and never re-reads process identity.reqwest_client_builder()) is created, create clients inside command handlers, which run afterexecute*has published, not during module registration. Documented indocs/attribution.mdand on the relevant APIs.execute*entrypoints after argv0 resolution, so an argv0 personality (an independentCliwith its own config) publishes its own identity rather than the dispatcher's.Cli::runintentionally does not publish, so running aCli(as tests do, concurrently) never mutates process-wide state. This is documented onCli::runand indocs/attribution.md.Behavior changes to review
HttpClientpreviously had no timeouts. It now has a 5s connect timeout and a 30s idle read timeout (per read, not per request). No total-request timeout is applied, so large streaming transfers that keep making progress are unaffected, but a server that takes more than 30s to send the first response byte now fails where it previously waited.sha2is no longer an optional dependency (attribution needs it unconditionally); it is removed from thepkce-authfeature list.is-ai-agent0.6.Out of scope / follow-ups
x-client-session) is a placeholder. It is trivially changed in one constant; I'd like reviewer opinion before this ships.traceparentforwarding is intentionally not included.Test plan
cargo fmt --all --checkcargo clippy --all-targets -- -D warnings(default features and--features pkce-auth)cargo test --all-targets(default features and--features pkce-auth)cargo test --doc,RUSTDOCFLAGS='-D warnings' cargo doc --no-deps,./cli-engine/scripts/check-module-size.sh,cargo rustdoc --lib -- -W missing-docs(0).default_headers(..)adds to (does not replace) the process defaults, an invalid default header is skipped rather than fatal, andHttpClientmerges process defaults under its own headers (client wins on a clash).CI=false/0/offhandling, the opt-out env var and its name derivation, configurable/invalid header names.Cli::client_identitycomposes the base user-agent (including an explicit override) with attribution.HttpClientrequests; an argv0 personality publishes its own identity on the execute path;Cli::runleaves the identity untouched.Manual verification
This was validated end to end against a downstream consumer CLI (
gddy) by pointing it at this branch with a local[patch.crates-io]override and running the real binary against a local capture server.Setup:
Test WITHOUT the change (baseline):
# On main: any request carries only `<name>/<version>`; no mode/agent tokens, no session header.Test WITH the change:
Cleanup:
# Remove the [patch.crates-io] block from the consumer's Cargo.toml.🤖 Generated with Claude Code