Skip to content
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1082,6 +1082,7 @@
"passthrough-override.test.ts": "responses",
"phase100-native-parity.test.ts": "e2e-style",
"pi-path-contract.test.ts": "clients",
"pinned-http-content-coding.test.ts": "lib",
"pinned-http.test.ts": "lib",
"pinned-https-get.test.ts": "images",
"plaintext-v2-agent-messages-server.test.ts": "server",
Expand Down
4 changes: 4 additions & 0 deletions src/lab/live/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ export function classifyTransportError(error: unknown): { classification: Failur
case "network_blocked": return { classification: "network_failure", secondaryCode: code };
case "region_blocked": return { classification: "region_blocked", secondaryCode: code };
case "provider_transient": return { classification: "provider_transient", secondaryCode: code };
// A response this transport cannot read is an upstream protocol failure. It is deterministic
// rather than transient, and attributing it to the harness would blame the runner for what
// the peer sent.
case "unreadable_response": return { classification: "protocol_failure", secondaryCode: code };
case "connect_timeout": case "first_byte_timeout": case "total_timeout":
return { classification: "timeout", secondaryCode: code };
case "inactivity_timeout":
Expand Down
5 changes: 5 additions & 0 deletions src/lab/live/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ export type TransportErrorCode =
| "output_byte_limit"
| "output_token_limit"
| "tool_call_limit"
// The peer answered, and the answer cannot be read: a content coding this transport cannot
// undo, or coded bytes that did not decode. Neither is a timeout, a budget, or a fault of
// the harness, so none of those codes describes it and every one of them would send an
// operator looking in the wrong place.
| "unreadable_response"
| "artifact_byte_limit"
| "memory_limit"
| "child_process_limit"
Expand Down
28 changes: 28 additions & 0 deletions src/lib/http-response-semantics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,31 @@
export function isNullBodyStatus(status: number): boolean {
return status === 204 || status === 205 || status === 304;
}

/** What a transport must do with the coding a response declares. */
export type ContentCoding =
| { kind: "identity" }
| { kind: "decodable"; format: "gzip" | "deflate" }
| { kind: "unsupported"; coding: string };

/**
* Classify the `content-encoding` a raw transport received.
*
* `fetch` undoes a content-coding below the Response constructor. A transport that assembles a
* body from a socket hands the coded bytes to whatever reads them instead: `response.json()`
* throws a SyntaxError on the gzip magic number and an SSE reader sees noise rather than frames.
* Only `gzip` and `deflate` can be undone with `DecompressionStream`, so anything else is
* reported as unsupported and each transport refuses it under its own error type rather than
* surfacing bytes no caller can parse.
*
* This reads the coding the response actually carries and nothing else. A caller whose
* `accept-encoding` names a coding this code cannot undo is not the problem; what the peer
* chose to send is.
*/
export function classifyContentCoding(headers: Headers): ContentCoding {
const coding = (headers.get("content-encoding") ?? "").trim().toLowerCase();
if (coding === "" || coding === "identity") return { kind: "identity" };
if (coding === "gzip" || coding === "x-gzip") return { kind: "decodable", format: "gzip" };
if (coding === "deflate") return { kind: "decodable", format: "deflate" };
return { kind: "unsupported", coding };
}
38 changes: 26 additions & 12 deletions src/lib/lab-live-pinned-sender.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,32 @@
import { PinnedHttpError, pinnedHttpGet, pinnedHttpPost } from "./pinned-http";
import { PinnedHttpError, pinnedHttpGet, pinnedHttpPost, type PinnedHttpErrorCode } from "./pinned-http";
import { TransportError } from "../lab/live/transport";
import type { LabCredentialLeaseV1, LabPinnedSender } from "../lab/live/types";
import type { LabCredentialLeaseV1, LabPinnedSender, TransportErrorCode } from "../lab/live/types";

/** Only response metadata required by current live assertions crosses into Lab. */
const LAB_RESPONSE_HEADER_ALLOWLIST = ["content-type"] as const;

/**
* Every pinned-transport failure code, mapped to the Lab transport taxonomy or to nothing.
*
* A switch over the codes that existed when it was written silently let later ones fall through
* to a raw rethrow. That is not the same as leaving them unclassified: Lab's executor turns an
* unrecognized error into `harness_failure` / `execution_error`, so a response the peer coded
* in a format this transport cannot undo was reported as a fault of the runner. Both unreadable
* answers therefore carry `unreadable_response`, which Lab classifies as a protocol failure.
* A total map keeps a future code from acquiring that misattribution by default.
*/
const LAB_TRANSPORT_FAILURES = {
connect_timeout: { code: "connect_timeout", message: "pinned provider connection timed out" },
first_byte_timeout: { code: "first_byte_timeout", message: "pinned provider first byte timed out" },
inactivity_timeout: { code: "inactivity_timeout", message: "pinned provider response stalled" },
output_byte_limit: { code: "output_byte_limit", message: "pinned provider response exceeded byte budget" },
content_decode_failed: { code: "unreadable_response", message: "pinned provider response did not decode" },
unsupported_content_encoding: {
code: "unreadable_response",
message: "pinned provider used a content-encoding this transport cannot decode",
},
} satisfies Record<PinnedHttpErrorCode, { code: TransportErrorCode; message: string }>;

/**
* Trusted credential/transport owner. Secret headers exist only in this non-Lab module and are
* consumed directly by the pinned HTTP primitive; they are never returned to Lab code.
Expand Down Expand Up @@ -33,16 +55,8 @@ export function createLabAuthorizedPinnedSender(
body = await response.text();
} catch (error) {
if (error instanceof PinnedHttpError) {
switch (error.code) {
case "connect_timeout":
throw new TransportError("connect_timeout", "pinned provider connection timed out");
case "first_byte_timeout":
throw new TransportError("first_byte_timeout", "pinned provider first byte timed out");
case "inactivity_timeout":
throw new TransportError("inactivity_timeout", "pinned provider response stalled");
case "output_byte_limit":
throw new TransportError("output_byte_limit", "pinned provider response exceeded byte budget");
}
const mapped = LAB_TRANSPORT_FAILURES[error.code];
throw new TransportError(mapped.code, mapped.message);
}
throw error;
}
Expand Down
130 changes: 127 additions & 3 deletions src/lib/pinned-http.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import http, { type ClientRequest, type IncomingMessage, type RequestOptions } from "node:http";
import https from "node:https";
import { isNullBodyStatus } from "./http-response-semantics";
import { classifyContentCoding, isNullBodyStatus } from "./http-response-semantics";

export type PinnedAddress = { address: string; family: number };

export type PinnedHttpErrorCode =
| "connect_timeout"
| "first_byte_timeout"
| "inactivity_timeout"
| "output_byte_limit";
| "output_byte_limit"
| "unsupported_content_encoding"
| "content_decode_failed";

export class PinnedHttpError extends Error {
override readonly name = "PinnedHttpError";
Expand All @@ -33,6 +35,99 @@ export interface PinnedHttpRequestOptions {
/** @deprecated Use {@link PinnedHttpRequestOptions}. */
export type PinnedHttpGetOptions = PinnedHttpRequestOptions;

/**
* Undo the content-coding this transport has to undo itself, under the caller's byte ceiling.
*
* `maxBytes` keeps its existing meaning for the bytes that arrive on the socket, and gains the
* same meaning for the bytes the caller ends up reading. Bounding only the coded side would let
* a small compressed response expand past a ceiling the caller set precisely so it would not
* have to hold an unbounded body in memory.
*
* A completed body is not torn down here. The response already ended, so there is nothing to
* release, and destroying it would take a connection the agent is entitled to reuse. That
* matches the identity path, which also only closes. Teardown belongs to the paths that end a
* response early: a decode failure, an exceeded ceiling, and a caller that cancels.
*
* Only a decoder failure is renamed. A mid-body reset, a stalled response and an exceeded
* socket-byte ceiling all reach this pipeline as "the stream failed", and calling any of them a
* decode failure would tell the caller the peer sent unreadable bytes when the truth is that the
* connection died. The source failure is recorded as it passes so the original error survives;
* what is left after that is the decompressor's own, and that one is named because the caller's
* alternative is a bare TypeError from a stream it never constructed.
*/
function decodedBody(
source: ReadableStream<Uint8Array>,
format: "gzip" | "deflate",
maxBytes: number | undefined,
context: string,
release: () => void,
): ReadableStream<Uint8Array> {
// Interposed purely to attribute failures. Once bytes enter the decompressor, a transport
// error and a corrupt trailer are indistinguishable from the far side of the pipe.
let sourceFailure: { error: unknown } | undefined;
const sourceReader = source.getReader();
const attributed = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const next = await sourceReader.read();
if (next.done) {
controller.close();
return;
}
controller.enqueue(next.value);
} catch (error) {
sourceFailure = { error };
controller.error(error);
}
},
cancel(reason) {
return sourceReader.cancel(reason);
},
});
// `DecompressionStream` declares its writable side as `WritableStream<BufferSource>`, and
// TypeScript measures `WritableStream` as invariant in its chunk type, so the pair is not
// assignable to `ReadableWritablePair<Uint8Array, Uint8Array>` even though every chunk this
// body produces is a valid `BufferSource`. The conversion states that relationship and
// nothing else; it does not widen what is actually written.
const decompressor = new DecompressionStream(format) as unknown as ReadableWritablePair<Uint8Array, Uint8Array>;
const reader = attributed.pipeThrough(decompressor).getReader();
let decoded = 0;
return new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const next = await reader.read();
if (next.done) {
controller.close();
return;
}
decoded += next.value.byteLength;
if (maxBytes !== undefined && decoded > maxBytes) {
throw new PinnedHttpError("output_byte_limit", `${context} exceeds ${maxBytes} byte cap`);
}
controller.enqueue(next.value);
} catch (error) {
// A failure the socket stream raised is the caller's answer, whatever shape it has.
// Only what the decompressor itself rejected is renamed.
const named = sourceFailure !== undefined
? sourceFailure.error
: error instanceof PinnedHttpError
? error
: new PinnedHttpError("content_decode_failed", `${context} could not decode its ${format} body`);
// Cancelling the decoded reader propagates back through the decompressor to the socket
// stream's own `cancel`, which destroys the request; `release` covers the case where
// that propagation is already finished.
await reader.cancel(named).catch(() => { /* already torn down */ });
controller.error(named);
release();
}
},
async cancel(reason) {
await reader.cancel(reason).catch(() => { /* already torn down */ });
release();
},
});
}

function pinnedHttpRequest(
url: string,
pinned: PinnedAddress,
Expand All @@ -56,6 +151,10 @@ function pinnedHttpRequest(
const maxBytes = options?.maxBytes;
const headers = new Headers(options?.headers);
headers.set("host", parsed.host);
// This transport assembles the response itself, so a coding it did not ask for becomes its own
// problem to undo. Ask for none by default and leave an explicit caller choice alone, which is
// the same rule `src/lib/socks5-fetch.ts` applies to the other raw route.
if (!headers.has("accept-encoding")) headers.set("accept-encoding", "identity");
if (body !== undefined && !headers.has("content-length")) {
headers.set("content-length", String(Buffer.byteLength(body)));
}
Expand Down Expand Up @@ -166,6 +265,24 @@ function pinnedHttpRequest(
return;
}

// The peer may have coded the body whatever this request asked for. Classify before the
// stream takes the socket so a coding this transport cannot undo fails on the ordinary
// error path rather than reaching the caller as bytes its parser cannot read.
const coding = classifyContentCoding(responseHeaders);
if (coding.kind === "unsupported") {
try { response.destroy(); } catch { /* ignore */ }
fail(new PinnedHttpError(
"unsupported_content_encoding",
`${context} returned an unsupported content-encoding: ${coding.coding}`,
));
return;
}
if (coding.kind === "decodable") {
responseHeaders.delete("content-encoding");
// The declared length counted the coded bytes, not what the caller now reads.
responseHeaders.delete("content-length");
}

let received = 0;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
Expand Down Expand Up @@ -207,7 +324,14 @@ function pinnedHttpRequest(

if (settled) return;
settled = true;
resolve(new Response(stream, { status, headers: responseHeaders }));
const release = () => {
try { response.destroy(); } catch { /* ignore */ }
try { req?.destroy(); } catch { /* ignore */ }
};
const payload = coding.kind === "decodable"
? decodedBody(stream, coding.format, maxBytes, context, release)
: stream;
resolve(new Response(payload, { status, headers: responseHeaders }));
};

const requestFn = parsed.protocol === "https:" ? https.request : http.request;
Expand Down
11 changes: 5 additions & 6 deletions src/lib/socks5-fetch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import net, { type Socket } from "node:net";
import tls, { type TLSSocket } from "node:tls";
import { isNullBodyStatus } from "./http-response-semantics";
import { classifyContentCoding, isNullBodyStatus } from "./http-response-semantics";

const DEFAULT_SOCKS5_PORT = 1080;
const SOCKS5_CONNECT_TIMEOUT_MS = 30_000;
Expand Down Expand Up @@ -531,11 +531,10 @@ function bodylessResponse(method: string, status: number): boolean {
* the coded path would fail responses that succeed uncompressed.
*/
function contentCodingFormat(headers: Headers): "gzip" | "deflate" | undefined {
const coding = (headers.get("content-encoding") ?? "").trim().toLowerCase();
if (coding === "" || coding === "identity") return undefined;
if (coding === "gzip" || coding === "x-gzip") return "gzip";
if (coding === "deflate") return "deflate";
throw new Socks5FetchError("SOCKS5 upstream returned an unsupported content-encoding: " + coding);
const coding = classifyContentCoding(headers);
if (coding.kind === "identity") return undefined;
if (coding.kind === "decodable") return coding.format;
throw new Socks5FetchError("SOCKS5 upstream returned an unsupported content-encoding: " + coding.coding);
}

export async function socks5Fetch(
Expand Down
24 changes: 22 additions & 2 deletions src/providers/quota/antigravity.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { antigravityUserAgent } from "../../adapters/client-fingerprint";
import { DestinationDnsResolutionError } from "../../lib/destination-policy";
import { PinnedHttpError } from "../../lib/pinned-http";
import { PinnedHttpError, type PinnedHttpErrorCode } from "../../lib/pinned-http";
import { ProviderOutboundPolicyError, providerOutboundPost, providerRedirectError, type ProviderOutboundDependencies } from "../../lib/provider-outbound";
import { getValidAccessToken } from "../../oauth";
import { getAccountCredential, getCredential } from "../../oauth/store";
Expand Down Expand Up @@ -193,10 +193,30 @@ type AntigravityQuotaProbeResult =
| { kind: "available"; quota: ProviderQuota; source: "google-antigravity:retrieveUserQuotaSummary" | "google-antigravity:fetchAvailableModels" }
| { kind: "unavailable"; failure: QuotaFailureCode; legacy: { kind: "null" } | { kind: "throw"; error: unknown } };

/**
* Every pinned-transport failure code, classified once.
*
* A conditional that named one code and sent the rest to `timeout` was correct only for as long
* as the union held exactly the codes it was written against. When the transport learned to
* report a coding it cannot undo, that answer was reported as a timeout, which is a different
* operational story entirely. A total map makes a new code a compile error here rather than a
* quiet misdiagnosis.
*/
const PINNED_QUOTA_FAILURES = {
connect_timeout: "timeout",
first_byte_timeout: "timeout",
inactivity_timeout: "timeout",
// The response arrived and cannot be used: too large, coded in a format this transport cannot
// undo, or coded bytes that did not decode. None of these is a timing failure.
output_byte_limit: "response_unusable",
unsupported_content_encoding: "response_unusable",
content_decode_failed: "response_unusable",
} satisfies Record<PinnedHttpErrorCode, QuotaFailureCode>;

function quotaTransportFailure(error: unknown): QuotaFailureCode {
if (error instanceof ProviderOutboundPolicyError) return "destination_blocked";
if (error instanceof DestinationDnsResolutionError) return "dns_failed";
if (error instanceof PinnedHttpError) return error.code === "output_byte_limit" ? "response_unusable" : "timeout";
if (error instanceof PinnedHttpError) return PINNED_QUOTA_FAILURES[error.code];
if (error instanceof DOMException && error.name === "TimeoutError") return "timeout";
return "transport_error";
}
Expand Down
19 changes: 19 additions & 0 deletions structure/transports/inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,4 +258,23 @@ peer that is entitled to keep it alive, and their representation headers are pre
arrived rather than decoded or refused, because there are no coded bytes to act on.
`tests/lib/transport-null-body.test.ts` covers both transports against a keep-alive peer.

## Raw transport content coding

Content coding is each raw transport's own obligation, and the pinned direct helper now carries
the same one the tunnel does. Provider outbound picks between these two routes, so decoding on
only one of them made the same gzip JSON readable or unreadable depending on operator egress
configuration. Both ask for `identity` unless the caller chose an `accept-encoding` itself,
decode `gzip` and `deflate`, drop the coding and the coded length once the bytes no longer match
them, and refuse any other coding by name instead of surfacing bytes no caller can parse. Only
the coding the response actually carries decides this; a preference list that mentions an
alternative this code cannot undo is not a refusal.

The pinned helper's `maxBytes` binds both sides of that decode: the bytes that arrive on the
socket keep their existing meaning, and the decoded bytes are bounded by the same ceiling, so a
small coded response cannot expand past the limit a caller set to bound what it holds. A decoder
failure surfaces as a named `PinnedHttpError`. Connection teardown belongs to the responses that
end early — a decode failure, an exceeded ceiling, a cancelled read; a response that completed
is left to the HTTP agent, which may pool or destroy it.
`tests/lib/pinned-http-content-coding.test.ts` covers both routes on the same payload.

Dashboard Fast-row persistence and client refresh follow the [Fast selector rows setting contract](../gui-and-management-api.md#fast-selector-rows-setting).
1 change: 1 addition & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -907,6 +907,7 @@
"passthrough-override.test.ts": "responses",
"phase100-native-parity.test.ts": "e2e-style",
"pi-path-contract.test.ts": "clients",
"pinned-http-content-coding.test.ts": "lib",
"pinned-http.test.ts": "lib",
"pinned-https-get.test.ts": "images",
"plaintext-v2-agent-messages-server.test.ts": "server",
Expand Down
Loading
Loading