diff --git a/docs/specs/agent-environments/http-transport/verification.md b/docs/specs/agent-environments/http-transport/verification.md index 9f3178b309..73dd3ff34a 100644 --- a/docs/specs/agent-environments/http-transport/verification.md +++ b/docs/specs/agent-environments/http-transport/verification.md @@ -288,3 +288,10 @@ keep-alive socket. The close decision now uses the incoming message's end of stream (test: a route that reads one chunk of a 256 KiB upload is answered with `Connection: close` and a closed socket; a route that reads it all keeps the connection). + +A thirteenth round (Codex): an `Accept` range with `q=0` no longer counts toward +the takeover guard (test: `application/json;q=0, text/event-stream` is refused +while the connected client keeps echoing). The same round noted that a failed +startup leaves cleanup of the already opened session and providers to a single +attempt; that path is the pre-existing stdio startup-failure cleanup, which the +HTTP mode now reaches on `EADDRINUSE`, and is left for the maintainers to decide. diff --git a/src/server/http/serveStreamableHttp.test.ts b/src/server/http/serveStreamableHttp.test.ts index f5620af518..5a4da3bea9 100644 --- a/src/server/http/serveStreamableHttp.test.ts +++ b/src/server/http/serveStreamableHttp.test.ts @@ -762,3 +762,22 @@ it("closes the connection when a route read only part of the body and keeps it a agent.destroy(); } }); + +it("does not let a zero-quality Accept range qualify a session takeover", async () => { + const { served } = await start(); + const { client } = await connect(served.url); + const url = new URL(served.url); + const refused = await rawHttpRequest(url, { + method: "POST", + headers: { + host: `127.0.0.1:${url.port}`, + "content-type": "application/json", + accept: "application/json;q=0, text/event-stream", + }, + body: await initializeRequest().text(), + }); + expect(refused.status).toBeGreaterThanOrEqual(400); + expect(refused.status).toBeLessThan(500); + expect(await echo(client, "kept")).toEqual([{ type: "text", text: "kept" }]); + await client.close(); +}); diff --git a/src/server/http/serveStreamableHttp.ts b/src/server/http/serveStreamableHttp.ts index 837feadec4..a134bacc57 100644 --- a/src/server/http/serveStreamableHttp.ts +++ b/src/server/http/serveStreamableHttp.ts @@ -115,10 +115,20 @@ const SHUTTING_DOWN = (): Response => /** The media type proper of one header value or Accept range: no parameters, lowercase. */ const mediaType = (value: string): string => (value.split(";")[0] ?? "").trim().toLowerCase(); +/** An Accept range's quality weight; a missing or unreadable `q` counts as 1, as in RFC 9110. */ +const quality = (range: string): number => { + const q = /;\s*q\s*=\s*([0-9.]+)/iu.exec(range)?.[1]; + const value = q === undefined ? 1 : Number(q); + return Number.isFinite(value) ? value : 1; +}; const hasStreamablePostHeaders = (request: Request): boolean => { - // Exact media ranges and type: `application/json-patch+json` or `text/event-streaming` do not count. + // Exact media ranges with a positive weight: `application/json-patch+json`, `text/event-streaming` + // and `application/json;q=0` do not count. const accepted = new Set( - (request.headers.get("accept") ?? "").split(",").map(mediaType), + (request.headers.get("accept") ?? "") + .split(",") + .filter((range) => quality(range) > 0) + .map(mediaType), ); return ( accepted.has("application/json") &&