Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .castiron.stats.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
schema_version: 1
generation_id: fae2b2d8-b06e-4a75-b296-019934b8950a
generation_id: b1cc4193-4b37-4231-bc0a-c97dcbe985f7
openapi_spec_hash: 4af7018ff56446e0398fc7946a92680f
openapi_transformed_spec_hash: b2689771e08c3fdc6ae8f0433dc2c784
config_hash: 5c00fe18ea913ed0186f9894adc02a70
codegen_sha: 05bcb472a761f436c08e48a9824417549667ae4b
codegen_hash: 042057870974b799dd5a454a004f5b934f9a0d33ac185c02c3946f19abd72fc4
public_codegen_sha: ff25c619e1f38e199d8a909019e01cf4be37adb5
codegen_sha: 4d3155b4c645e90b0a2d754982db6245de8b372d
codegen_hash: a6e4616dbb72afa61505b2333668b0f6787e9640cb63a06a09776b943c10e731
public_codegen_sha: 65094c1637432c123c310d03e0cda1e7e6f1c997
1 change: 1 addition & 0 deletions scripts/test-packed-package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ const packedPackagePath = require('node:path');
[
"declare module 'ws' {",
' export interface ClientOptions {',
' followRedirects?: boolean | undefined;',
' headers?: Record<string, string> | undefined;',
' }',
' export class WebSocket {',
Expand Down
1 change: 1 addition & 0 deletions src/resources/beta/responses/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export class ResponsesWS extends ResponsesWSBase<NodeWebSocket> {
...authHeaders,
...this._wsOptions?.headers,
},
followRedirects: false,
});
return new NodeWebSocket(ws);
}
Expand Down
1 change: 1 addition & 0 deletions src/resources/responses/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export class ResponsesWS extends ResponsesWSBase<NodeWebSocket> {
...authHeaders,
...this._wsOptions?.headers,
},
followRedirects: false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Document that Responses redirects are always disabled

Callers still see followRedirects as a supported field because ResponsesWSClientOptions extends WS.ClientOptions, but this override now silently ignores followRedirects: true on both stable and beta surfaces. Existing applications relying on same-origin or credential-free redirects will fail with the redirect response, while the canonical Responses WebSocket documentation merely says that WebSocket options can be passed through. Document this new invariant in the public JSDoc or canonical guide so the intentional breaking contract is discoverable.

AGENTS.md reference: AGENTS.md:L72-L74

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

expected for now; can followup if confusing

@apcha-oai apcha-oai Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

context: WS.ClientOptions is node behavior so there's not a nice way to remove the key unless we exclude it or override with an inline deprecated marker.

this is blocking some things. Might do that separately to enforce followRedirects?: false only but punting for now to unblock downstream work

});
return new NodeWebSocket(ws);
}
Expand Down
4 changes: 2 additions & 2 deletions tests/bedrock-websocket-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,15 +408,15 @@ describe.each([
{ name: 'stable', Responses: StableResponsesWS },
{ name: 'beta', Responses: BetaResponsesWS },
])('$name Responses WebSocket redirect options', ({ Responses }) => {
test('preserves explicitly enabled redirects without sensitive headers', () => {
test('disables explicitly enabled redirects without sensitive headers', () => {
const websocket = new Responses(createUnauthenticatedClient(), {
followRedirects: true,
headers: { 'X-Custom': 'value' },
});

expect(websocket.socket.platformSocket).toBe(lastNodeSocket());
expect(lastNodeSocket().options).toMatchObject({
followRedirects: true,
followRedirects: false,
headers: { 'X-Custom': 'value' },
});
expect(lastNodeSocket().options.headers).not.toHaveProperty('Authorization');
Expand Down
29 changes: 20 additions & 9 deletions tests/realtime-websocket-custom-credential-redirects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,11 +255,11 @@ describe.each([

expect(result.sourceCredentials).toEqual([value]);
expect(result.destinationCredentials).toEqual([]);
expect(result.redirects).toBe(1);
expect(result.error.message).toBe('WebSocket was closed before the connection was established');
expect(result.redirects).toBe(0);
expect(result.error.message).toBe('Unexpected server response: 302');
expect(result.publicErrors).toEqual([
expect.objectContaining({
message: 'WebSocket was closed before the connection was established',
message: 'Unexpected server response: 302',
}),
]);
},
Expand All @@ -278,7 +278,11 @@ describe.each([

expect(result.sourceCredentials).toEqual([value]);
expect(result.destinationCredentials).toEqual([]);
expect(result.redirects).toBe(1);
expect(result.redirects).toBe(0);
expect(result.error.message).toBe(`Unexpected server response: ${status}`);
expect(result.publicErrors).toEqual([
expect.objectContaining({ message: `Unexpected server response: ${status}` }),
]);
},
);

Expand All @@ -293,10 +297,14 @@ describe.each([

expect(result.sourceCredentials).toEqual([value]);
expect(result.destinationCredentials).toEqual([]);
expect(result.redirects).toBe(1);
expect(result.redirects).toBe(0);
expect(result.error.message).toBe('Unexpected server response: 302');
expect(result.publicErrors).toEqual([
expect.objectContaining({ message: 'Unexpected server response: 302' }),
]);
});

test.each([false, true])('preserves benign %s-origin redirect behavior', async (sameOrigin) => {
test.each([false, true])('disables benign %s-origin redirects', async (sameOrigin) => {
const value = 'ordinary-nonsensitive-header-value';
const result = await inspectRedirect({
Responses,
Expand All @@ -306,8 +314,11 @@ describe.each([
});

expect(result.sourceCredentials).toEqual([value]);
expect(result.destinationCredentials).toEqual([value]);
expect(result.redirects).toBe(1);
expect(result.error.message).toBe('Unexpected server response: 200');
expect(result.destinationCredentials).toEqual([]);
expect(result.redirects).toBe(0);
expect(result.error.message).toBe('Unexpected server response: 302');
expect(result.publicErrors).toEqual([
expect.objectContaining({ message: 'Unexpected server response: 302' }),
]);
});
});
4 changes: 2 additions & 2 deletions tests/realtime-websocket-redirects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,9 +482,9 @@ describe.each([

expect(sourceCredentials).toEqual([value]);
expect(destinationRequests).toEqual([]);
expect(redirects).toHaveBeenCalledTimes(1);
expect(redirects).not.toHaveBeenCalled();
expect(errors).toHaveBeenCalledWith(
expect.objectContaining({ message: 'WebSocket was closed before the connection was established' }),
expect.objectContaining({ message: 'Unexpected server response: 302' }),
);
} finally {
await closeServers(source, destination);
Expand Down
Loading