From 80a12e30c1e40cb4a49698cb9b556d74fb055174 Mon Sep 17 00:00:00 2001 From: BSG2000 Date: Fri, 31 Jul 2026 18:39:14 +0800 Subject: [PATCH] fix(openai): bound realtime websocket auth errors --- .../openai/realtime-voice-provider.test.ts | 97 ++++++++++++++++++- extensions/openai/realtime-voice-provider.ts | 41 +++++++- 2 files changed, 132 insertions(+), 6 deletions(-) diff --git a/extensions/openai/realtime-voice-provider.test.ts b/extensions/openai/realtime-voice-provider.test.ts index 701d9d32c2a1..7b8eca6101b7 100644 --- a/extensions/openai/realtime-voice-provider.test.ts +++ b/extensions/openai/realtime-voice-provider.test.ts @@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js"; const INTERNAL_REALTIME_VOICE_PROVIDER = Symbol.for("openclaw.internal.realtime-voice-provider.v1"); +const OPENAI_REALTIME_REJECTED_KEY_MESSAGE = + "OpenAI Realtime rejected the selected API key. Update or remove the active OpenAI API-key source"; function readInternalRealtimeVoiceProviderApi(provider: object) { return Reflect.get(provider, INTERNAL_REALTIME_VOICE_PROVIDER) as { @@ -2114,7 +2116,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { Buffer.from( JSON.stringify({ type: "error", - error: { message: "Incorrect API key provided" }, + error: { message: "Incorrect API key provided: sk-proj-***" }, }), ), ); @@ -2123,18 +2125,105 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { Buffer.from( JSON.stringify({ type: "error", - error: { message: "Incorrect API key provided" }, + error: { message: "Incorrect API key provided: sk-proj-***" }, }), ), ); - await expect(connecting).rejects.toThrow("Incorrect API key provided"); + await expect(connecting).rejects.toThrow(OPENAI_REALTIME_REJECTED_KEY_MESSAGE); expect(onError).not.toHaveBeenCalled(); expect(onClose).not.toHaveBeenCalled(); expect(socket.closed).toBe(true); expect(bridge.isConnected()).toBe(false); }); + it("normalizes structured direct OpenAI startup auth errors", async () => { + const provider = buildOpenAIRealtimeVoiceProvider(); + const bridge = provider.createBridge({ + providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }); + const connecting = bridge.connect(); + const socket = FakeWebSocket.instances[0]; + if (!socket) { + throw new Error("expected bridge to create a websocket"); + } + + socket.readyState = FakeWebSocket.OPEN; + socket.emit("open"); + socket.emit( + "message", + Buffer.from( + JSON.stringify({ + type: "error", + error: { + type: "invalid_request_error", + code: "invalid_api_key", + message: "Invalid API key", + }, + }), + ), + ); + + await expect(connecting).rejects.toThrow(OPENAI_REALTIME_REJECTED_KEY_MESSAGE); + expect(bridge.isConnected()).toBe(false); + }); + + it("normalizes direct OpenAI socket handshake auth errors", async () => { + const provider = buildOpenAIRealtimeVoiceProvider(); + const bridge = provider.createBridge({ + providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }); + const connecting = bridge.connect(); + const socket = FakeWebSocket.instances[0]; + if (!socket) { + throw new Error("expected bridge to create a websocket"); + } + + socket.emit("error", new Error("Unexpected server response: 401")); + + await expect(connecting).rejects.toThrow(OPENAI_REALTIME_REJECTED_KEY_MESSAGE); + expect(bridge.isConnected()).toBe(false); + }); + + it.each([ + [ + "Azure deployment", + { + apiKey: "sk-test", // pragma: allowlist secret + azureEndpoint: "https://example.openai.azure.com", + azureDeployment: "realtime-prod", + }, + ], + [ + "custom endpoint", + { + apiKey: "sk-test", // pragma: allowlist secret + azureEndpoint: "https://realtime-proxy.example.com", + }, + ], + ])("preserves %s startup auth errors", async (_label, providerConfig) => { + const provider = buildOpenAIRealtimeVoiceProvider(); + const bridge = provider.createBridge({ + providerConfig, + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }); + const connecting = bridge.connect(); + const socket = FakeWebSocket.instances[0]; + if (!socket) { + throw new Error("expected bridge to create a websocket"); + } + + socket.emit("error", new Error("Unexpected server response: 401")); + + await expect(connecting).rejects.toThrow("Unexpected server response: 401"); + expect(bridge.isConnected()).toBe(false); + }); + it("keeps a retried connection ready after delayed startup failure close", async () => { const provider = buildOpenAIRealtimeVoiceProvider(); const onClose = vi.fn(); @@ -2163,7 +2252,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { ), ); - await expect(failedConnect).rejects.toThrow("Incorrect API key provided"); + await expect(failedConnect).rejects.toThrow(OPENAI_REALTIME_REJECTED_KEY_MESSAGE); expect(failedSocket.deferredClose).toBeDefined(); const retryConnect = bridge.connect(); diff --git a/extensions/openai/realtime-voice-provider.ts b/extensions/openai/realtime-voice-provider.ts index 68f13eb388c7..5c1f45305394 100644 --- a/extensions/openai/realtime-voice-provider.ts +++ b/extensions/openai/realtime-voice-provider.ts @@ -306,6 +306,30 @@ const KEYCHAIN_SECRET_REF_RE = /^keychain:([^:]+):([^:]+)$/; const KEYCHAIN_LOOKUP_TIMEOUT_MS = 5000; const resolvedKeychainSecretRefCache = new Map(); +function isDirectOpenAIRealtimeWebSocketUrl(value: string): boolean { + try { + return new URL(value).hostname === "api.openai.com"; + } catch { + return false; + } +} + +function isOpenAIRealtimeStartupAuthFailure(error: unknown): boolean { + const record = + typeof error === "object" && error !== null ? (error as Record) : undefined; + const status = record?.status ?? record?.statusCode; + const rawCode = record?.code ?? record?.errorCode; + const code = typeof rawCode === "string" ? rawCode.toLowerCase() : ""; + const message = readRealtimeErrorDetail(error).toLowerCase(); + return ( + status === 401 || + code === "invalid_api_key" || + message.includes("invalid_api_key") || + message.includes("incorrect api key provided") || + message.includes("unexpected server response: 401") + ); +} + function resolveKeychainSecretRef(value: string): string | undefined { const trimmed = value.trim(); const match = KEYCHAIN_SECRET_REF_RE.exec(trimmed); @@ -880,7 +904,14 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge { try { const event = JSON.parse(data.toString()) as RealtimeEvent; if (event.type === "error" && !reachedReady) { - rejectStartup(new Error(readRealtimeErrorDetail(event.error))); + // Only direct OpenAI auth failures get bounded remediation. Azure, + // custom endpoints, and non-auth startup details remain provider-owned. + rejectStartup( + isDirectOpenAIRealtimeWebSocketUrl(url) && + isOpenAIRealtimeStartupAuthFailure(event.error) + ? new Error(OPENAI_REALTIME_CONFIGURED_API_KEY_REJECTED) + : new Error(readRealtimeErrorDetail(event.error)), + ); return; } this.handleEvent(event, lifecycleConnection); @@ -914,7 +945,13 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge { }, }); if (!reachedReady) { - rejectStartup(error instanceof Error ? error : new Error(String(error))); + const startupError = error instanceof Error ? error : new Error(String(error)); + rejectStartup( + isDirectOpenAIRealtimeWebSocketUrl(url) && + isOpenAIRealtimeStartupAuthFailure(startupError) + ? new Error(OPENAI_REALTIME_CONFIGURED_API_KEY_REJECTED) + : startupError, + ); return; } this.config.onError?.(error instanceof Error ? error : new Error(String(error)));