From 7527fd53a243a46992268a89693bf3d1f8e5c70a Mon Sep 17 00:00:00 2001 From: Brian Corrigan <816+svperfecta@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:10:12 -0600 Subject: [PATCH 1/4] fix(ai): honor StreamOptions.headers in Bedrock and Cursor Both transports built their request header maps from scratch and never read options.headers, so caller-supplied tracing or attribution headers were silently dropped while working on every other provider. Bedrock now merges caller headers before SigV4 signing so the signature covers them; Cursor merges them into its HTTP/2 request. Merging exposed three rules each transport enforces that a caller cannot be allowed to violate, all covered by regressions that fail against the previous code: - SigV4 generates host/x-amz-date/x-amz-content-sha256/x-amz-security-token for itself. signRequest signs a caller value but returns its own, so a caller supplying any of them would sign one set of bytes and send another. - HTTP/2 forbids the HTTP/1 connection-specific headers, and node's http2.request() throws on them rather than ignoring them. - Header names are compared case-insensitively on the wire, so a caller Content-Type beside a fixed content-type is a duplicate rather than an override. Caller names are lower-cased and copies of headers each request sets itself are dropped. --- packages/ai/CHANGELOG.md | 4 + packages/ai/src/providers/amazon-bedrock.ts | 35 ++++ packages/ai/src/providers/cursor.ts | 69 +++++++ .../ai/test/bedrock-caller-headers.test.ts | 175 ++++++++++++++++++ .../ai/test/cursor-caller-headers.test.ts | 64 +++++++ 5 files changed, 347 insertions(+) create mode 100644 packages/ai/test/bedrock-caller-headers.test.ts create mode 100644 packages/ai/test/cursor-caller-headers.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 0e26ffbf0..cb3f3b9a1 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed the Amazon Bedrock and Cursor transports ignoring `StreamOptions.headers`; both built their request headers from scratch, so caller-supplied tracing or attribution headers were silently dropped while working on every other provider ([#8046](https://github.com/can1357/oh-my-pi/pull/8046)). + ## [17.2.12] - 2026-08-08 ### Fixed diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index 453d90e15..aa72ada0e 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -47,6 +47,16 @@ import { decodeEventStream } from "./aws-eventstream"; import { signRequest } from "./aws-sigv4"; import { transformMessages } from "./transform-messages"; +/** + * Headers SigV4 generates for itself. A caller cannot be allowed to supply these: + * `signRequest` would sign the caller's value but return its own, so the signature + * would not match what goes on the wire. + */ +const SIGNER_OWNED_HEADERS = new Set(["host", "x-amz-date", "x-amz-content-sha256", "x-amz-security-token"]); + +/** Headers the Bedrock request sets itself; a caller copy in any casing duplicates them. */ +const BEDROCK_RESERVED_HEADERS = new Set(["content-type", "accept", "authorization"]); + export type BedrockThinkingDisplay = "summarized" | "omitted"; export interface BedrockOptions extends StreamOptions { @@ -356,7 +366,32 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = ( const bodyText = JSON.stringify(commandInput); const body = new TextEncoder().encode(bodyText); + // Caller headers are merged BEFORE signing, so SigV4 covers them and they + // reach the wire. Bedrock built its header map from scratch and ignored + // `options.headers` entirely, so tracing/attribution headers set by a + // caller (or by a `before_provider_headers` extension) were silently + // dropped here while working on every other provider. Content-type and + // accept stay last: the eventstream framing is not the caller's to change. + // + // The signer's OWN headers are dropped first, and that is load-bearing: + // `signRequest` lets a caller value overwrite `host`/`x-amz-*` in the map + // it signs, but always RETURNS the generated ones, which `requestHeaders` + // below then puts on the wire. A caller supplying any of them would sign + // one set of values and send another, and Bedrock would reject every + // request with a signature mismatch. + // Lower-cased, and names the request sets itself are dropped. Keeping a + // caller `Content-Type` beside the fixed `content-type` leaves TWO object + // keys: SigV4 signs one value while fetch canonicalizes both into a single + // comma-joined wire header, so AWS validates different bytes than were + // signed and rejects the request. + const callerHeaders: Record = {}; + for (const [name, value] of Object.entries(options?.headers ?? {})) { + const field = name.toLowerCase(); + if (SIGNER_OWNED_HEADERS.has(field) || BEDROCK_RESERVED_HEADERS.has(field)) continue; + callerHeaders[field] = value; + } const baseHeaders: Record = { + ...callerHeaders, "content-type": "application/json", accept: "application/vnd.amazon.eventstream", }; diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index d276a27b0..e0ed2530b 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -223,6 +223,60 @@ import { export const CURSOR_API_URL = "https://api2.cursor.sh"; export const CURSOR_CLIENT_VERSION = "cli-2026.07.23-e383d2b"; +/** + * HTTP/1 connection-specific headers that HTTP/2 forbids. Node's `http2.request()` + * throws `ERR_HTTP2_INVALID_CONNECTION_HEADERS` on these rather than dropping + * them, so a caller sending one would kill the request outright. + */ +const HTTP2_FORBIDDEN_HEADERS = new Set([ + "connection", + "keep-alive", + "proxy-connection", + "transfer-encoding", + "upgrade", + "http2-settings", +]); + +/** + * Header names the Cursor request sets for itself. A caller copy in ANY casing + * has to go: the spread below adds the fixed lower-case name regardless, and two + * spellings of one field are a duplicate rather than an override. + */ +const CURSOR_RESERVED_HEADERS = new Set([ + "content-type", + "connect-protocol-version", + "te", + "authorization", + "x-ghost-mode", + "x-cursor-client-version", + "x-cursor-client-type", + "x-request-id", +]); + +/** + * Reduce caller-supplied headers to what this HTTP/2 request can legally carry. + * + * Everything is lower-cased, because HTTP/2 field names are lower-case and node + * compares them that way. A caller `Authorization` next to the fixed + * `authorization` does not lose to it, it DUPLICATES it, and node throws + * `ERR_HTTP2_HEADER_SINGLE_VALUE` before the request goes out. Same for a `TE` + * that is not `trailers`. Node throws on all three classes here rather than + * ignoring them, so a miss turns a harmless header into a dead request. + * + * Exported for tests. + */ +export function sanitizeCursorCallerHeaders(headers: Record | undefined): Record { + const sanitized: Record = {}; + for (const [name, value] of Object.entries(headers ?? {})) { + const field = name.toLowerCase(); + if (field.startsWith(":")) continue; + if (HTTP2_FORBIDDEN_HEADERS.has(field)) continue; + if (CURSOR_RESERVED_HEADERS.has(field)) continue; + sanitized[field] = value; + } + return sanitized; +} + const CURSOR_PROXY_TUNNEL_TIMEOUT_MS = 30_000; /** @@ -545,7 +599,22 @@ export const streamCursor: StreamFunction<"cursor-agent"> = ( const baseUrl = model.baseUrl || CURSOR_API_URL; const requestPath = "/agent.v1.AgentService/Run"; + // Caller headers are additive, and are spread FIRST so the protocol + // framing, auth, and request id below always win. Cursor built this map + // from scratch and never read `options.headers`, so tracing/attribution + // headers set by a caller (or a `before_provider_headers` extension) were + // silently dropped here while working on other providers. + // + // Two classes are stripped because node's http2 client THROWS on them + // rather than ignoring them, which would turn a harmless header into a + // dead request: pseudo-headers, which belong to the transport, and the + // HTTP/1 connection-specific headers HTTP/2 forbids outright + // (ERR_HTTP2_INVALID_CONNECTION_HEADERS). `te` needs no filtering here — + // HTTP/2 allows it only as `trailers`, which is exactly what the fixed + // set below re-applies over anything a caller sent. + const callerHeaders = sanitizeCursorCallerHeaders(options?.headers); const requestHeaders = { + ...callerHeaders, ":method": "POST", ":path": requestPath, "content-type": "application/connect+proto", diff --git a/packages/ai/test/bedrock-caller-headers.test.ts b/packages/ai/test/bedrock-caller-headers.test.ts new file mode 100644 index 000000000..fcb3a098d --- /dev/null +++ b/packages/ai/test/bedrock-caller-headers.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from "bun:test"; +import { streamBedrock } from "@oh-my-pi/pi-ai/providers/amazon-bedrock"; +import { crc32 } from "@oh-my-pi/pi-ai/providers/aws-eventstream"; +import type { Context, FetchImpl, Model } from "@oh-my-pi/pi-ai/types"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; + +// Caller headers (including `before_provider_headers` extension edits) reach the +// Bedrock request, but SigV4's own headers must never come from the caller: +// `signRequest` signs the caller's value and then RETURNS its own, so the wire +// would carry different bytes than the signature covers and Bedrock would reject +// every request. Exercised through the real signing path, not a unit stub. + +/** + * Run `body` with dummy AWS credentials, restoring the environment immediately. + * + * Scoped to the one test rather than the file: a `beforeAll` override leaves + * every later Bedrock file in the same Bun process on the dummy-credential path + * until `afterAll` runs, which is the full-suite hazard `AGENTS.md` rules out. + */ +async function withSkippedAuth(body: () => Promise): Promise { + const original = process.env.AWS_BEDROCK_SKIP_AUTH; + process.env.AWS_BEDROCK_SKIP_AUTH = "1"; + try { + return await body(); + } finally { + if (original === undefined) delete process.env.AWS_BEDROCK_SKIP_AUTH; + else process.env.AWS_BEDROCK_SKIP_AUTH = original; + } +} + +function encodeFrame(headers: Record, payload: Uint8Array): Uint8Array { + const headerParts: Uint8Array[] = []; + for (const [name, value] of Object.entries(headers)) { + const nameBytes = new TextEncoder().encode(name); + const valueBytes = new TextEncoder().encode(value); + const part = new Uint8Array(1 + nameBytes.length + 1 + 2 + valueBytes.length); + const partView = new DataView(part.buffer); + let cursor = 0; + partView.setUint8(cursor, nameBytes.length); + cursor += 1; + part.set(nameBytes, cursor); + cursor += nameBytes.length; + partView.setUint8(cursor, 7); + cursor += 1; + partView.setUint16(cursor, valueBytes.length, false); + cursor += 2; + part.set(valueBytes, cursor); + headerParts.push(part); + } + const headerLength = headerParts.reduce((total, part) => total + part.length, 0); + const headerBytes = new Uint8Array(headerLength); + let offset = 0; + for (const part of headerParts) { + headerBytes.set(part, offset); + offset += part.length; + } + const totalLength = 12 + headerLength + payload.length + 4; + const frame = new Uint8Array(totalLength); + const view = new DataView(frame.buffer); + view.setUint32(0, totalLength, false); + view.setUint32(4, headerLength, false); + view.setUint32(8, crc32(frame.subarray(0, 8)), false); + frame.set(headerBytes, 12); + frame.set(payload, 12 + headerLength); + view.setUint32(totalLength - 4, crc32(frame.subarray(0, totalLength - 4)), false); + return frame; +} + +function bedrockEvent(eventType: string, payload: string): Uint8Array { + return encodeFrame({ ":message-type": "event", ":event-type": eventType }, new TextEncoder().encode(payload)); +} + +/** Captures the headers actually sent, and replies with a minimal valid stream. */ +function capturingFetch(seen: { headers?: Record }): FetchImpl { + const frames = [ + bedrockEvent("messageStart", '{"role":"assistant"}'), + bedrockEvent("contentBlockDelta", '{"contentBlockIndex":0,"delta":{"text":"hi"}}'), + bedrockEvent("contentBlockStop", '{"contentBlockIndex":0}'), + bedrockEvent("messageStop", '{"stopReason":"end_turn"}'), + bedrockEvent("metadata", '{"usage":{"inputTokens":1,"outputTokens":1,"totalTokens":2}}'), + ]; + return Object.assign( + async (_input: string | URL | Request, init?: RequestInit) => { + seen.headers = (init?.headers ?? {}) as Record; + let index = 0; + const body = new ReadableStream({ + pull(controller) { + if (index < frames.length) controller.enqueue(frames[index++]!); + else controller.close(); + }, + }); + return new Response(body, { status: 200, headers: { "content-type": "application/vnd.amazon.eventstream" } }); + }, + { preconnect: fetch.preconnect }, + ); +} + +function model(): Model<"bedrock-converse-stream"> { + return buildModel({ + id: "anthropic.claude-3-5-sonnet-20241022-v2:0", + name: "Claude 3.5 Sonnet", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, + }); +} + +const context: Context = { messages: [{ role: "user", content: "hi", timestamp: 0 }] }; + +describe("Bedrock caller headers", () => { + it("forwards caller headers but never lets them supply SigV4's own", async () => { + const seen: { headers?: Record } = {}; + await withSkippedAuth(async () => { + const stream = streamBedrock(model(), context, { + region: "us-east-1", + fetch: capturingFetch(seen), + headers: { + "x-trace": "kept", + // Every header SigV4 generates for itself. Signed as the caller's value + // but sent as the signer's, these would break the signature. + host: "evil.example.com", + "x-amz-date": "19700101T000000Z", + "x-amz-content-sha256": "deadbeef", + "x-amz-security-token": "forged", + }, + }); + await stream.result(); + }); + + const headers = seen.headers ?? {}; + // The benign caller header still reaches the request: that is the feature. + expect(headers["x-trace"]).toBe("kept"); + // None of the signer-owned values are the caller's. + expect(headers.host).not.toBe("evil.example.com"); + expect(headers["x-amz-date"]).not.toBe("19700101T000000Z"); + expect(headers["x-amz-content-sha256"]).not.toBe("deadbeef"); + expect(headers["x-amz-security-token"]).not.toBe("forged"); + // And the request was actually signed, so this is the real path. + expect(headers.authorization ?? headers.Authorization).toContain("AWS4-HMAC-SHA256"); + }); + + // A caller spelling differing only in case leaves two object keys: SigV4 signs + // one, fetch comma-joins both onto the wire, and AWS rejects the mismatch. + it("does not leave a differently cased duplicate of a header it sets itself", async () => { + const seen: { headers?: Record } = {}; + await withSkippedAuth(async () => { + const stream = streamBedrock(model(), context, { + region: "us-east-1", + fetch: capturingFetch(seen), + headers: { + "Content-Type": "text/plain", + Accept: "text/plain", + Host: "evil.example.com", + "X-Trace": "kept", + }, + }); + await stream.result(); + }); + + const headers = seen.headers ?? {}; + const names = Object.keys(headers).map(name => name.toLowerCase()); + // Each field appears exactly once, whatever casing the caller used. + for (const field of ["content-type", "accept", "host"]) { + expect(names.filter(name => name === field).length).toBeLessThanOrEqual(1); + } + expect(headers["content-type"]).toBe("application/json"); + // Ordinary caller headers still land, lower-cased. + expect(headers["x-trace"]).toBe("kept"); + }); +}); diff --git a/packages/ai/test/cursor-caller-headers.test.ts b/packages/ai/test/cursor-caller-headers.test.ts new file mode 100644 index 000000000..c79706e17 --- /dev/null +++ b/packages/ai/test/cursor-caller-headers.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "bun:test"; +import { sanitizeCursorCallerHeaders } from "@oh-my-pi/pi-ai/providers/cursor"; + +// Cursor now forwards caller headers (including `before_provider_headers` +// extension edits), and it speaks HTTP/2. Node's `http2.request()` THROWS on +// pseudo-headers and on the HTTP/1 connection-specific headers HTTP/2 forbids, +// rather than dropping them, so anything that slips through here does not +// degrade the request, it kills it. + +describe("sanitizeCursorCallerHeaders", () => { + it("keeps ordinary caller headers", () => { + expect(sanitizeCursorCallerHeaders({ "x-trace": "abc", "x-waygate-activity": "mode=plan" })).toEqual({ + "x-trace": "abc", + "x-waygate-activity": "mode=plan", + }); + }); + + it("drops HTTP/2 pseudo-headers, which belong to the transport", () => { + expect(sanitizeCursorCallerHeaders({ ":path": "/evil", ":method": "GET", "x-keep": "1" })).toEqual({ + "x-keep": "1", + }); + }); + + it("drops every connection-specific header HTTP/2 forbids", () => { + const sanitized = sanitizeCursorCallerHeaders({ + connection: "keep-alive", + "keep-alive": "timeout=5", + "proxy-connection": "keep-alive", + "transfer-encoding": "chunked", + upgrade: "h2c", + "http2-settings": "AAMAAABkAAQAoAAAAAIAAAAA", + "x-keep": "1", + }); + expect(sanitized).toEqual({ "x-keep": "1" }); + }); + + it("matches forbidden names case-insensitively", () => { + expect(sanitizeCursorCallerHeaders({ Connection: "close", "Transfer-Encoding": "chunked" })).toEqual({}); + }); + + // A caller spelling differing only in case does not OVERRIDE the fixed header, + // it duplicates it, and node rejects the request rather than picking one. + it("drops caller copies of headers the request sets itself, in any casing", () => { + const sanitized = sanitizeCursorCallerHeaders({ + Authorization: "Bearer stolen", + TE: "gzip", + "Content-Type": "text/plain", + "X-Request-Id": "forged", + "x-ghost-mode": "false", + "x-keep": "1", + }); + expect(sanitized).toEqual({ "x-keep": "1" }); + }); + + // HTTP/2 field names are lower-case, so the surviving names are normalized and + // two caller spellings of one field collapse instead of duplicating. + it("lower-cases surviving names", () => { + expect(sanitizeCursorCallerHeaders({ "X-Trace": "abc" })).toEqual({ "x-trace": "abc" }); + }); + + it("handles absent headers", () => { + expect(sanitizeCursorCallerHeaders(undefined)).toEqual({}); + }); +}); From 85461399f15eb33c86733c3fad40d61c603ade68 Mon Sep 17 00:00:00 2001 From: Brian Corrigan <816+svperfecta@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:10:49 -0600 Subject: [PATCH 2/4] Point the changelog entry at its own PR --- packages/ai/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index cb3f3b9a1..7a66341d4 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Fixed the Amazon Bedrock and Cursor transports ignoring `StreamOptions.headers`; both built their request headers from scratch, so caller-supplied tracing or attribution headers were silently dropped while working on every other provider ([#8046](https://github.com/can1357/oh-my-pi/pull/8046)). +- Fixed the Amazon Bedrock and Cursor transports ignoring `StreamOptions.headers`; both built their request headers from scratch, so caller-supplied tracing or attribution headers were silently dropped while working on every other provider ([#8107](https://github.com/can1357/oh-my-pi/pull/8107)). ## [17.2.12] - 2026-08-08 From 9dc35941875c7f89da552f07aba675091ae4a3b5 Mon Sep 17 00:00:00 2001 From: Brian Corrigan <816+svperfecta@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:15:47 -0600 Subject: [PATCH 3/4] Test the Cursor transport contract, and treat host as transport-owned Three review findings. host is transport-owned even though this request never sets it: node's http2 client suppresses the :authority it derives from the URL when a plain host header is present, so a caller value silently retargeted the request at another virtual host. Added to the reserved set. The tests exercised only the exported sanitizer, so if streamCursor stopped merging callerHeaders every one of them would still have passed while the wire lost the headers. They now drive streamCursor against a local HTTP/2 server and assert what the server actually received: an ordinary header arrives, names are lower-cased, connection-specific and pseudo-headers neither arrive nor kill the request, the request's own headers are not overridable, and the authority is not retargetable. All five fail if the merge is removed. That also lets sanitizeCursorCallerHeaders stop being exported. The changelog entry pointed at #8046, the parent work, rather than the PR being released here, and lacked the contributor credit the external-contribution convention requires. --- packages/ai/CHANGELOG.md | 2 +- packages/ai/src/providers/cursor.ts | 9 +- .../ai/test/cursor-caller-headers.test.ts | 207 ++++++++++++++---- 3 files changed, 171 insertions(+), 47 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 7a66341d4..02db2ab3b 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Fixed the Amazon Bedrock and Cursor transports ignoring `StreamOptions.headers`; both built their request headers from scratch, so caller-supplied tracing or attribution headers were silently dropped while working on every other provider ([#8107](https://github.com/can1357/oh-my-pi/pull/8107)). +- Fixed the Amazon Bedrock and Cursor transports ignoring `StreamOptions.headers`; both built their request headers from scratch, so caller-supplied tracing or attribution headers were silently dropped while working on every other provider ([#8107](https://github.com/can1357/oh-my-pi/pull/8107) by [@svperfecta](https://github.com/svperfecta)). ## [17.2.12] - 2026-08-08 diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index e0ed2530b..f3fc04fc5 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -251,6 +251,11 @@ const CURSOR_RESERVED_HEADERS = new Set([ "x-cursor-client-version", "x-cursor-client-type", "x-request-id", + // Transport-owned even though this request never sets it: node's http2 client + // suppresses the `:authority` it derives from the URL when a plain `host` + // header is present, so a caller value here silently retargets the request at + // a different virtual host. + "host", ]); /** @@ -262,10 +267,8 @@ const CURSOR_RESERVED_HEADERS = new Set([ * `ERR_HTTP2_HEADER_SINGLE_VALUE` before the request goes out. Same for a `TE` * that is not `trailers`. Node throws on all three classes here rather than * ignoring them, so a miss turns a harmless header into a dead request. - * - * Exported for tests. */ -export function sanitizeCursorCallerHeaders(headers: Record | undefined): Record { +function sanitizeCursorCallerHeaders(headers: Record | undefined): Record { const sanitized: Record = {}; for (const [name, value] of Object.entries(headers ?? {})) { const field = name.toLowerCase(); diff --git a/packages/ai/test/cursor-caller-headers.test.ts b/packages/ai/test/cursor-caller-headers.test.ts index c79706e17..15d37f251 100644 --- a/packages/ai/test/cursor-caller-headers.test.ts +++ b/packages/ai/test/cursor-caller-headers.test.ts @@ -1,64 +1,185 @@ -import { describe, expect, it } from "bun:test"; -import { sanitizeCursorCallerHeaders } from "@oh-my-pi/pi-ai/providers/cursor"; +import { afterEach, describe, expect, it } from "bun:test"; +import * as http2 from "node:http2"; +import { create, toBinary } from "@bufbuild/protobuf"; +import { streamCursor } from "@oh-my-pi/pi-ai/providers/cursor"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; +import { + AgentServerMessageSchema, + InteractionUpdateSchema, + TextDeltaUpdateSchema, + TurnEndedUpdateSchema, +} from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb"; -// Cursor now forwards caller headers (including `before_provider_headers` -// extension edits), and it speaks HTTP/2. Node's `http2.request()` THROWS on -// pseudo-headers and on the HTTP/1 connection-specific headers HTTP/2 forbids, -// rather than dropping them, so anything that slips through here does not -// degrade the request, it kills it. +// Cursor forwards caller headers (including `before_provider_headers` extension +// edits), and it speaks HTTP/2. These assert the TRANSPORT contract against a +// real local HTTP/2 server rather than the sanitizer in isolation: if +// `streamCursor` stopped merging caller headers, or merged the wrong ones, a +// helper-level test would still pass while the wire lost them. +// +// Two classes must never reach `http2.request()`, because node THROWS on them +// rather than ignoring them, turning a harmless header into a dead request: +// pseudo-headers and HTTP/1 connection-specific headers. A third class — +// headers the request sets for itself — must not arrive duplicated, since names +// are matched case-insensitively on the wire. -describe("sanitizeCursorCallerHeaders", () => { - it("keeps ordinary caller headers", () => { - expect(sanitizeCursorCallerHeaders({ "x-trace": "abc", "x-waygate-activity": "mode=plan" })).toEqual({ - "x-trace": "abc", - "x-waygate-activity": "mode=plan", - }); +let server: http2.Http2Server | undefined; +const sessions = new Set(); +let received: http2.IncomingHttpHeaders = {}; + +function frameConnectMessage(data: Uint8Array, flags = 0): Buffer { + const frame = Buffer.alloc(5 + data.length); + frame[0] = flags; + frame.writeUInt32BE(data.length, 1); + frame.set(data, 5); + return frame; +} + +function textDeltaFrame(text: string): Buffer { + const message = create(AgentServerMessageSchema, { + message: { + case: "interactionUpdate", + value: create(InteractionUpdateSchema, { + message: { case: "textDelta", value: create(TextDeltaUpdateSchema, { text }) }, + }), + }, + }); + return frameConnectMessage(toBinary(AgentServerMessageSchema, message)); +} + +function turnEndedFrame(): Buffer { + const message = create(AgentServerMessageSchema, { + message: { + case: "interactionUpdate", + value: create(InteractionUpdateSchema, { + message: { case: "turnEnded", value: create(TurnEndedUpdateSchema, {}) }, + }), + }, + }); + return frameConnectMessage(toBinary(AgentServerMessageSchema, message)); +} + +/** Records the headers the client actually sent, then replies with a clean turn. */ +async function startServer(): Promise { + server = http2.createServer(); + server.on("session", session => { + sessions.add(session); + session.on("close", () => sessions.delete(session)); + }); + server.on("stream", (stream: http2.ServerHttp2Stream, headers: http2.IncomingHttpHeaders) => { + stream.on("data", () => {}); + received = headers; + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + stream.write(textDeltaFrame("ok")); + stream.write(turnEndedFrame()); + stream.end(); }); - it("drops HTTP/2 pseudo-headers, which belong to the transport", () => { - expect(sanitizeCursorCallerHeaders({ ":path": "/evil", ":method": "GET", "x-keep": "1" })).toEqual({ - "x-keep": "1", - }); + const listening = Promise.withResolvers(); + server.once("error", listening.reject); + server.listen(0, "127.0.0.1", listening.resolve); + await listening.promise; + const address = server.address(); + if (!address || typeof address === "string") throw new Error("expected the fixture server to bind a tcp port"); + return `http://127.0.0.1:${address.port}`; +} + +async function stopServer(): Promise { + for (const session of sessions) session.destroy(); + sessions.clear(); + if (!server) return; + const closing = server; + server = undefined; + const closed = Promise.withResolvers(); + closing.close(error => (error ? closed.reject(error) : closed.resolve())); + await closed.promise; +} + +function makeModel(baseUrl: string): Model<"cursor-agent"> { + return buildModel({ + id: "cursor-caller-headers-fixture", + name: "Cursor caller headers fixture", + api: "cursor-agent", + provider: "cursor", + baseUrl, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1, + maxTokens: 1, + }); +} + +const context: Context = { messages: [{ role: "user", content: "headers", timestamp: 1 }] }; + +/** Drive one request to completion and hand back the headers the server saw. */ +async function send(headers: Record): Promise { + const baseUrl = await startServer(); + const stream = streamCursor(makeModel(baseUrl), context, { apiKey: "test-token", headers }); + for await (const _event of stream) { + // drain + } + await stream.result(); + return received; +} + +afterEach(async () => { + received = {}; + await stopServer(); +}); + +describe("Cursor caller headers reach the wire", () => { + it("delivers an ordinary caller header to the server", async () => { + const sent = await send({ "x-trace": "abc", "x-waygate-activity": "mode=plan" }); + expect(sent["x-trace"]).toBe("abc"); + expect(sent["x-waygate-activity"]).toBe("mode=plan"); }); - it("drops every connection-specific header HTTP/2 forbids", () => { - const sanitized = sanitizeCursorCallerHeaders({ + it("normalizes a caller header name to lower case", async () => { + const sent = await send({ "X-Trace": "abc" }); + expect(sent["x-trace"]).toBe("abc"); + }); + + // The request still has to go out. Node throws on these rather than dropping + // them, so a leak here is a dead request, not a missing header. + it("survives HTTP/1 connection-specific headers and pseudo-headers", async () => { + const sent = await send({ connection: "keep-alive", "keep-alive": "timeout=5", - "proxy-connection": "keep-alive", "transfer-encoding": "chunked", upgrade: "h2c", - "http2-settings": "AAMAAABkAAQAoAAAAAIAAAAA", - "x-keep": "1", + ":path": "/evil", + "x-trace": "kept", }); - expect(sanitized).toEqual({ "x-keep": "1" }); + // The request completed, and the benign header still landed. + expect(sent["x-trace"]).toBe("kept"); + expect(sent[":path"]).toBe("/agent.v1.AgentService/Run"); + expect(sent.connection).toBeUndefined(); + expect(sent["transfer-encoding"]).toBeUndefined(); }); - it("matches forbidden names case-insensitively", () => { - expect(sanitizeCursorCallerHeaders({ Connection: "close", "Transfer-Encoding": "chunked" })).toEqual({}); - }); - - // A caller spelling differing only in case does not OVERRIDE the fixed header, - // it duplicates it, and node rejects the request rather than picking one. - it("drops caller copies of headers the request sets itself, in any casing", () => { - const sanitized = sanitizeCursorCallerHeaders({ + it("does not let a caller override the headers the request sets itself", async () => { + const sent = await send({ Authorization: "Bearer stolen", - TE: "gzip", "Content-Type": "text/plain", + TE: "gzip", "X-Request-Id": "forged", - "x-ghost-mode": "false", - "x-keep": "1", + "x-trace": "kept", }); - expect(sanitized).toEqual({ "x-keep": "1" }); + expect(sent.authorization).toBe("Bearer test-token"); + expect(sent["content-type"]).toBe("application/connect+proto"); + expect(sent.te).toBe("trailers"); + expect(sent["x-request-id"]).not.toBe("forged"); + expect(sent["x-trace"]).toBe("kept"); }); - // HTTP/2 field names are lower-case, so the surviving names are normalized and - // two caller spellings of one field collapse instead of duplicating. - it("lower-cases surviving names", () => { - expect(sanitizeCursorCallerHeaders({ "X-Trace": "abc" })).toEqual({ "x-trace": "abc" }); - }); - - it("handles absent headers", () => { - expect(sanitizeCursorCallerHeaders(undefined)).toEqual({}); + // A plain `host` header suppresses the `:authority` node derives from the URL, + // so a caller value would silently retarget the request at another vhost. + it("does not let a caller header retarget the request authority", async () => { + const sent = await send({ Host: "evil.example.com", "x-trace": "kept" }); + expect(sent[":authority"]).not.toBe("evil.example.com"); + expect(sent[":authority"]).toContain("127.0.0.1"); + expect(sent.host).toBeUndefined(); + expect(sent["x-trace"]).toBe("kept"); }); }); From 3bfce1d7b43c882b6c4daf470b5733689da8d3bc Mon Sep 17 00:00:00 2001 From: Brian Corrigan <816+svperfecta@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:32:48 -0600 Subject: [PATCH 4/4] Treat content-length as transport-owned in both providers The fetch layer recomputes Bedrock's content-length from the serialized body, so a caller value would be covered by the SigV4 signature but never sent, and AWS rejects the mismatch. Cursor streams its Connect body after the headers (initial frame, heartbeats, tool responses), so no caller-supplied length can describe it and an HTTP/2 peer resets the stream once the body diverges. Reserved in both, and both regressions now send a Content-Length to prove it never reaches the wire. --- packages/ai/src/providers/amazon-bedrock.ts | 5 ++++- packages/ai/src/providers/cursor.ts | 4 ++++ packages/ai/test/bedrock-caller-headers.test.ts | 5 ++++- packages/ai/test/cursor-caller-headers.test.ts | 4 ++++ 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index aa72ada0e..4473f46d2 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -55,7 +55,10 @@ import { transformMessages } from "./transform-messages"; const SIGNER_OWNED_HEADERS = new Set(["host", "x-amz-date", "x-amz-content-sha256", "x-amz-security-token"]); /** Headers the Bedrock request sets itself; a caller copy in any casing duplicates them. */ -const BEDROCK_RESERVED_HEADERS = new Set(["content-type", "accept", "authorization"]); +// `content-length` included: the fetch layer recomputes it from the serialized +// body, so a caller value would be signed but not sent, and AWS rejects the +// mismatch. +const BEDROCK_RESERVED_HEADERS = new Set(["content-type", "accept", "authorization", "content-length"]); export type BedrockThinkingDisplay = "summarized" | "omitted"; diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index f3fc04fc5..23bcae9af 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -256,6 +256,10 @@ const CURSOR_RESERVED_HEADERS = new Set([ // header is present, so a caller value here silently retargets the request at // a different virtual host. "host", + // The Connect body is streamed after the headers (initial frame, heartbeats, + // tool responses), so no caller-supplied length can describe it and an HTTP/2 + // peer resets the stream once the body diverges. + "content-length", ]); /** diff --git a/packages/ai/test/bedrock-caller-headers.test.ts b/packages/ai/test/bedrock-caller-headers.test.ts index fcb3a098d..f9b251650 100644 --- a/packages/ai/test/bedrock-caller-headers.test.ts +++ b/packages/ai/test/bedrock-caller-headers.test.ts @@ -156,6 +156,9 @@ describe("Bedrock caller headers", () => { "Content-Type": "text/plain", Accept: "text/plain", Host: "evil.example.com", + // Recomputed by the fetch layer from the serialized body, so a caller + // value would be signed but never sent. + "Content-Length": "999", "X-Trace": "kept", }, }); @@ -165,7 +168,7 @@ describe("Bedrock caller headers", () => { const headers = seen.headers ?? {}; const names = Object.keys(headers).map(name => name.toLowerCase()); // Each field appears exactly once, whatever casing the caller used. - for (const field of ["content-type", "accept", "host"]) { + for (const field of ["content-type", "accept", "host", "content-length"]) { expect(names.filter(name => name === field).length).toBeLessThanOrEqual(1); } expect(headers["content-type"]).toBe("application/json"); diff --git a/packages/ai/test/cursor-caller-headers.test.ts b/packages/ai/test/cursor-caller-headers.test.ts index 15d37f251..14b21613a 100644 --- a/packages/ai/test/cursor-caller-headers.test.ts +++ b/packages/ai/test/cursor-caller-headers.test.ts @@ -164,12 +164,16 @@ describe("Cursor caller headers reach the wire", () => { "Content-Type": "text/plain", TE: "gzip", "X-Request-Id": "forged", + // The Connect body is streamed after the headers, so no caller length can + // describe it; an HTTP/2 peer resets the stream once the body diverges. + "Content-Length": "999", "x-trace": "kept", }); expect(sent.authorization).toBe("Bearer test-token"); expect(sent["content-type"]).toBe("application/connect+proto"); expect(sent.te).toBe("trailers"); expect(sent["x-request-id"]).not.toBe("forged"); + expect(sent["content-length"]).toBeUndefined(); expect(sent["x-trace"]).toBe("kept"); });