fix(kotlin): project the transport error before it becomes the network message
The Kotlin side of #869 (Rust) and #871 (Swift).
The leak. Ktor's timeout exceptions render the request URL in their message — HttpRequestTimeoutException: Request timeout has expired [url=…, request_timeout=… ms], and the engine-built ConnectTimeoutException and SocketTimeoutException likewise. BasecampHttpClient built its Network error as "Network error: ${e.message}" with the raw exception chained, and handed the raw exception to RequestResult.error, so a timeout on a request whose query is signed put the signature into the SDK error's message, toString(), stackTraceToString(), cause chain and the request-end hook: everywhere the error gets logged. The download flow's hop 1 was already projected (#837); the generic request path beneath it, and the OAuth discovery, device and token-exchange paths, were not.
The fix. One helper, redactTransportError in TransportErrors.kt, with displayUrl (moved there from Download.kt, where hop 1's hooks already used it) and displayOrigin beside it. A Ktor timeout exception is rebuilt from parts this SDK chooses: the same class, so a caller — and the device flow's own isConnectionTimeout — classifies it as before, and the URL the SDK issued (a redirect's target is the transport's business and is not rendered) projected to origin and path when it sits on the configured base URL without userinfo, and to the origin alone anywhere else, because a storage service can sign the path as readily as the query (SPEC §9's "projection, not redaction"; the same trusted-origin rule the Go SDKs use). Every other exception is the transport's own diagnostic — a JVM socket failure names host and port at most — and passes through unchanged, cause chain included. The budget rendered beside the projected URL is the one the SDK itself installed (configureClient, or the OAuth clients' own constants); for a caller-supplied HttpClient none is claimed, since the number in the transport's own message is text it wrote and is not read. Sites: both transport catches in BasecampHttpClient (requestWithRetry, requestBinaryWithRetry), projected once before the request-end hook so hooks and the caller see the same shape; and the OAuth transport catches in Discovery.kt, Device.kt (authorization request and token poll) and Exchange.kt, whose endpoints are a discovered issuer's and render as origin only.
The test. TransportErrorProjectionJvmTest drives the shipped CIO engine's own HttpTimeout against a listener that accepts and never answers, with ?signature=SECRETVALUE on the request, and asserts SECRETVALUE is absent from message, toString(), stackTraceToString(), hint, every link of the cause chain and RequestResult.error; that the cause is still an HttpRequestTimeoutException; and that the message keeps http://127.0.0.1:<port>/12345/blob and the configured budget. TransportErrorProjectionTest covers the same site, and the binary request path, through MockEngine for all three Ktor timeout shapes, a Connection refused passing through by class and message, and the trusted-origin, foreign-origin, userinfo and unparsable projections. On main the engine test fails with:
expected: <Network error: Request timeout has expired [url=http://127.0.0.1:60465/12345/blob, request_timeout=300 ms]> but was: <Network error: Request timeout has expired [url=http://127.0.0.1:60465/12345/blob?signature=SECRETVALUE, request_timeout=300 ms]>
Gates run locally: ./gradlew :basecamp-sdk:check, ./gradlew :conformance:run.
Review rounds. Copilot and Codex found the budget rendered for a caller-supplied HttpClient (now omitted), the device flow's dropped budgets (now passed), and the request budget rendered on a connect/socket timeout by the OAuth wrappers, which install only a request timeout (the helper now takes the two budgets separately); all fixed and tested.
Watching the review loop on this one through to convergence.