Skip to content

fix(responses): reject function-based API keys in WebSocket constructors - #2586

Open
sylvesterkaczmarek wants to merge 5 commits into
openai:mainfrom
sylvesterkaczmarek:fix/responses-ws-function-api-key
Open

fix(responses): reject function-based API keys in WebSocket constructors#2586
sylvesterkaczmarek wants to merge 5 commits into
openai:mainfrom
sylvesterkaczmarek:fix/responses-ws-function-api-key

Conversation

@sylvesterkaczmarek

@sylvesterkaczmarek sylvesterkaczmarek commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Responses WebSocket authentication is synchronous, so a client with an unresolved function-based apiKey cannot authenticate the socket. At the same time, a function-backed client that has already run _callApiKey() has a resolved string in client.apiKey and should remain usable.

The current fix enforces that invariant at the shared stable and beta ResponsesWSBase._authHeaders() boundary instead of only in the concrete Node constructors:

  • if client.apiKey is a resolved string, use it for the Authorization header regardless of whether the original client option was a function;
  • if no resolved key exists, fail before opening a WebSocket rather than silently performing an unauthenticated handshake;
  • because the guard lives in the published base classes, custom transport subclasses cannot bypass it.

Review follow-up

This addresses both review findings on the original head:

  1. already-resolved function credentials are accepted;
  2. unresolved credentials are rejected at the shared authentication boundary, covering stable, beta, and custom ResponsesWSBase subclasses.

Tests

Stable and beta regression coverage verifies both states:

  • an unresolved function-backed client throws before a socket is opened and the credential provider is not invoked synchronously;
  • after _callApiKey() resolves the provider, constructing the same WebSocket surface no longer fails the credential guard.

Fresh code/security review is running on commit d2d038ff615b3e693abf8e1a01aba0a2bbd9c140.

@sylvesterkaczmarek
sylvesterkaczmarek requested a review from a team as a code owner September 3, 2026 22:27
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-06T08:19:43.335693Z d1165a9 New commits
🔒 Security Review Completed 2026-09-06T08:17:40.958520Z d1165a9 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 395125547e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/resources/responses/ws.ts Outdated
'ResponsesWS from "openai/resources/responses/ws" requires the "ws" package but it could not be loaded.',
);
}
if (typeof client._options.apiKey === 'function') {

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 Allow already-resolved function credentials

When a function-backed client has already run _callApiKey(), client.apiKey contains the resolved token while _options.apiKey intentionally remains a function. This condition therefore rejects both stable and beta constructors before _authHeaders() can send that token, breaking the rotating Bedrock Responses WebSocket path already exercised in tests/bedrock-websocket-security.test.ts:251-269, as well as similarly pre-resolved OpenAI credentials. Gate on the unresolved state or provide an async factory/bypass analogous to the Realtime surfaces.

AGENTS.md reference: AGENTS.md:L50-L57

Useful? React with 👍 / 👎.

Comment thread src/resources/responses/ws.ts Outdated
'ResponsesWS from "openai/resources/responses/ws" requires the "ws" package but it could not be loaded.',
);
}
if (typeof client._options.apiKey === 'function') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Enforce the guard at the shared WebSocket boundary

This guard covers only the two concrete Node constructors, but ResponsesWSBase remains available through the published openai/resources/{,beta/}responses/ws-base import subpaths and exposes _connectInitial() to subclasses. A custom transport subclass can therefore pass an unresolved function-backed client to the base and still reach _authHeaders(), which returns no authorization header, preserving the unauthenticated-handshake bug this change is meant to prevent. Enforce the unresolved-provider invariant in the shared base/authentication boundary instead of only these callers.

AGENTS.md reference: AGENTS.md:L31-L35

Useful? React with 👍 / 👎.

@HAYDEN-OAI HAYDEN-OAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The fail-fast behavior is useful, but please preserve already-resolved function credentials before merging. I confirmed the regression described in the existing inline finding: _callApiKey() stores the token in client.apiKey and leaves _options.apiKey as a function, so the new check rejects a client that the existing WebSocket authentication path can use successfully.

Please enforce the unresolved-credential condition at the shared authentication boundary, preserving already-resolved credentials and supported provider authentication on both stable and beta surfaces. An async construction path that resolves credentials before connecting is another option. The important behavior is to prevent an unauthenticated connection without rejecting a usable resolved token.

Static review of 395125547ef1fce61aa1213bd32bf0345b4f707d; no tests run.

@HAYDEN-OAI HAYDEN-OAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Requesting changes for two issues affecting both stable and beta Responses WebSocket constructors: the new protected-member access does not compile, and the guard rejects provider clients even when their credential has already been resolved. A focused TypeScript compiler check confirmed TS2445 in both changed files; the credential regression was traced through the client and WebSocket authentication paths. No tests were run.

Comment thread src/resources/responses/ws.ts Outdated
'ResponsesWS from "openai/resources/responses/ws" requires the "ws" package but it could not be loaded.',
);
}
if (typeof client._options.apiKey === 'function') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Avoid accessing the protected client options directly

OpenAI._options is declared protected in src/client.ts, and ResponsesWS does not extend OpenAI. This new access therefore produces TS2445 and prevents the source from compiling. A focused compiler check confirmed the same error at line 23 in both the stable and beta files. Use an accessible client method or helper to inspect credential configuration, and apply the correction to both constructors.

Comment thread src/resources/beta/responses/ws.ts Outdated
'ResponsesWS from "openai/resources/beta/responses/ws" requires the "ws" package but it could not be loaded.',
);
}
if (typeof client._options.apiKey === 'function') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Preserve already-resolved provider credentials

After a normal REST request or await client._callApiKey(), a function-backed client has the resolved token in client.apiKey, while _options.apiKey remains a function. Reusing that client for Responses WebSocket previously authenticated successfully through _authHeaders(), but this guard now always throws. This also affects BedrockOpenAI configured with bedrockTokenProvider. Unlike Realtime, Responses has no async factory that preserves this path. Restrict rejection to unresolved credentials or provide an async factory that supports provider clients; the stable constructor needs the same correction.

@sylvesterkaczmarek

Copy link
Copy Markdown
Contributor Author

Addressed both review issues on the current head. The concrete constructors no longer access protected client options. Resolved credential validation now lives in the shared ResponsesWSBase authentication boundary, so unresolved function credentials are rejected there while a credential already resolved through _callApiKey() remains usable by both stable and beta WebSockets. Focused WebSocket tests pass (4/4), and TypeScript compilation succeeds.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d2d038ff61

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/resources/responses/ws-base.ts Outdated
Comment on lines +607 to +610
if (!apiKey) {
throw new OpenAIError(
'Cannot open a Responses WebSocket without a resolved string apiKey. Resolve function-based credentials before constructing the WebSocket.',
);

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 Honor caller-supplied WebSocket credentials

When a client has no apiKey because it uses workload identity or another caller-managed credential, this throws before ResponsesWS._createSocket() can merge options.headers. That breaks the documented new ResponsesWS(client, { headers: { Authorization: ... } }) path in docs/responses.md:157-160 and the existing stable/beta custom-header tests. Reject unresolved credentials only when the final handshake has no caller-supplied authentication, rather than requiring every client to expose an API key.

AGENTS.md reference: AGENTS.md:L52-L57

Useful? React with 👍 / 👎.

const client = new OpenAI({ apiKey });
expect(await client._callApiKey()).toBe(true);

expect(() => new Responses(client)).not.toThrow();

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 Mock and close the socket in the acceptance test

No ws mock is installed in this test, so after _callApiKey() resolves the token, each new Responses(client) immediately starts a real connection to wss://api.openai.com. The socket is discarded without an error listener or close(), meaning a normal authentication or network failure reaches _onError() as an unhandled rejection, while a successful connection leaves an open handle; mock the transport and assert the emitted authorization header instead.

Useful? React with 👍 / 👎.

@sylvesterkaczmarek

Copy link
Copy Markdown
Contributor Author

Addressed both new findings. The shared Responses WebSocket boundary now rejects only an unresolved function-backed apiKey, while clients with caller-managed Authorization headers or other non-apiKey authentication remain allowed. Added an internal client predicate for the unresolved-function state. The acceptance test now uses a mocked ws transport and closes it, so it never opens a real network connection. Validation: 10 focused WebSocket tests passed and TypeScript --noEmit completed successfully.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5199e017f5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/resources/responses/ws-base.ts Outdated
protected _authHeaders(): Record<string, string> {
if (this._client.apiKey) {
return { Authorization: `Bearer ${this._client.apiKey}` };
if (this._client._hasUnresolvedApiKey()) {

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 Honor explicit auth for unresolved API-key functions

When apiKey is a function and the caller follows docs/responses.md:157-160 by passing a resolved Authorization header in the ResponsesWS options, this unconditional guard throws before _createSocket() merges wsOptions.headers, so the documented workaround cannot open either the stable or beta socket. The fresh final tree still performs this check before the caller headers are merged, while the added custom-header test only covers a client without a function-based key. Make the validation consider the final caller-supplied authentication rather than rejecting solely from client state.

AGENTS.md reference: AGENTS.md:L52-L57

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.

Fixed in b22c606. The unresolved function-key check now happens at the final Node WebSocket header boundary, so an explicit caller Authorization header satisfies authentication while an unresolved key without explicit auth still fails closed. Added stable/beta coverage for both Authorization and authorization. Focused suite: 10/10 passed; tsc --noEmit passed.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b22c606afa

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/resources/responses/ws.ts Outdated
Comment on lines +35 to +36
const hasExplicitAuthorization = Object.entries(this._wsOptions?.headers ?? {}).some(
([name, value]) => name.toLowerCase() === 'authorization' && value != null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate the same authorization snapshot you dispatch

When headers.Authorization is accessor-backed, the spread above can snapshot an empty string while this Object.entries() read invokes the accessor again and sees a non-null token. The guard then passes, but new WS.WebSocket receives the already-built empty authorization value, so an unresolved function-key client still attempts the unauthenticated handshake this change is intended to prevent. Validate the final headers snapshot passed to ws and require a usable nonempty value; the beta copy has the same defect.

AGENTS.md reference: AGENTS.md:L98-L110

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.

Fixed in d1165a9. Credential validation now inspects the exact final socketOptions snapshot passed to ws; accessor-backed headers are read only during that snapshot, and empty/whitespace credential values do not satisfy the guard. Added a regression where Authorization returns empty on the snapshot and a token on a hypothetical second read. Focused suite: 20/20 passed; tsc --noEmit passed.

Comment thread src/resources/responses/ws.ts Outdated
Comment on lines +35 to +38
const hasExplicitAuthorization = Object.entries(this._wsOptions?.headers ?? {}).some(
([name, value]) => name.toLowerCase() === 'authorization' && value != null,
);
if (this._client._hasUnresolvedApiKey() && !hasExplicitAuthorization) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Recognize all supported caller credentials

When a function-backed client authenticates a custom Responses endpoint through another supported ws mechanism, such as auth: 'user:pass' or an X-API-Key header, this check throws solely because there is no Authorization header, even though the parent revision passed those credentials through to ws. Fresh evidence in the final tree is tests/realtime-websocket-redirects.test.ts:401-426, which explicitly treats Basic auth, proxy authorization, cookies, and X-API-Key as caller credentials on these Responses transports. Check the final credential-bearing WebSocket options rather than only this one header; the beta copy has the same regression.

AGENTS.md reference: AGENTS.md:L52-L57

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.

Fixed in d1165a9. The unresolved-key guard now accepts the supported caller credential mechanisms already exercised by the Responses transport: Basic auth, Authorization, Proxy-Authorization, Cookie, and X-API-Key, all checked on the final socket options. Added stable/beta regressions for each. Focused suite: 20/20 passed; tsc --noEmit passed.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d1165a9d0d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +17 to +21
const CREDENTIAL_HEADERS = new Set([
'authorization',
'proxy-authorization',
'cookie',
'x-api-key',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use the shared custom-credential classification

When a function-backed client authenticates a custom endpoint with X-Auth-Token, X-Amz-Security-Token, or another caller-defined credential header, this four-name allowlist rejects the connection even though the parent revision forwarded it. Fresh evidence beyond the earlier credential comment is src/internal/ws.ts:47-74, which already classifies credential-bearing WebSocket headers, while tests/realtime-websocket-custom-credential-redirects.test.ts:11-49 explicitly exercises these additional names. Reuse that shared classification rather than maintaining a narrower stable/beta list.

AGENTS.md reference: AGENTS.md:L31-L35

Useful? React with 👍 / 👎.


function hasCredentialValue(value: unknown): boolean {
if (Array.isArray(value)) return value.some(hasCredentialValue);
return value != null && String(value).trim().length > 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Snapshot serialized credential values before validation

When JavaScript supplies an object-valued credential header with a stateful toString(), this conversion can return a nonempty token here, allowing the guard to pass, while the unchanged object passed to ws is stringified again by Node and can emit an empty or different header. Fresh evidence relative to the earlier accessor finding is that the final code snapshots the header property but not its serialized value. Normalize the credential value into the same primitive snapshot dispatched to ws; the beta copy has the same issue.

AGENTS.md reference: AGENTS.md:L106-L110

Useful? React with 👍 / 👎.

followRedirects: false,
});
};
if (this._client._hasUnresolvedApiKey() && !hasExplicitWebSocketCredential(socketOptions)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate the API-key snapshot used for the header

When client.apiKey is accessor-backed, _authHeaders() reads it once to build the outgoing header and this call reads it again through _hasUnresolvedApiKey(). A stateful accessor can return null on the first read and a string on the second, making the unresolved check return false while the already-built header set remains empty. Fresh evidence beyond the prior header-accessor finding is this separate second read of the client credential; carry the original API-key/header snapshot into validation instead.

AGENTS.md reference: AGENTS.md:L106-L110

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants