diff --git a/CHANGELOG.md b/CHANGELOG.md index 52b0b2d42f9..56ca5144345 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Providers/OpenAI: resolve `keychain::` `OPENAI_API_KEY` refs before creating OpenAI Realtime browser sessions or voice bridges, with a bounded cached Keychain lookup. Fixes #72120. Thanks @ctbritt. - Discord/gateway: reconnect when the gateway socket closes while waiting for the shared IDENTIFY concurrency window, instead of silently skipping IDENTIFY and leaving the bot online but unresponsive. Fixes #74617. Thanks @zeeskdr-ai. - Telegram/startup: use the existing `getMe` request guard for the gateway bot probe instead of a fixed 2.5-second budget, and honor higher `timeoutSeconds` configs for slow Telegram API paths. Fixes #75783. Thanks @tankotan. - Control UI/slash commands: keep fallback command metadata on a browser-safe registry path, so provider thinking runtime imports cannot blank the Web UI with `process is not defined`. Fixes #75987. Thanks @novkien. diff --git a/extensions/openai/realtime-voice-provider.test.ts b/extensions/openai/realtime-voice-provider.test.ts index c9e4e0cfb15..56541e8f8e3 100644 --- a/extensions/openai/realtime-voice-provider.test.ts +++ b/extensions/openai/realtime-voice-provider.test.ts @@ -2,7 +2,7 @@ import { REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ } from "openclaw/plugin-sdk/rea import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js"; -const { FakeWebSocket, fetchWithSsrFGuardMock } = vi.hoisted(() => { +const { FakeWebSocket, execFileSyncMock, fetchWithSsrFGuardMock } = vi.hoisted(() => { type Listener = (...args: unknown[]) => void; class MockWebSocket { @@ -51,7 +51,19 @@ const { FakeWebSocket, fetchWithSsrFGuardMock } = vi.hoisted(() => { } } - return { FakeWebSocket: MockWebSocket, fetchWithSsrFGuardMock: vi.fn() }; + return { + FakeWebSocket: MockWebSocket, + execFileSyncMock: vi.fn(), + fetchWithSsrFGuardMock: vi.fn(), + }; +}); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execFileSync: execFileSyncMock, + }; }); vi.mock("ws", () => ({ @@ -91,6 +103,7 @@ function createJsonResponse(body: unknown, init?: { status?: number }): Response describe("buildOpenAIRealtimeVoiceProvider", () => { beforeEach(() => { FakeWebSocket.instances = []; + execFileSyncMock.mockReset(); fetchWithSsrFGuardMock.mockReset(); }); @@ -168,6 +181,97 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { ); }); + it("resolves keychain OPENAI_API_KEY refs before creating browser sessions", async () => { + vi.stubEnv("OPENAI_API_KEY", "keychain:openclaw:OPENAI_REALTIME_BROWSER_TEST"); + execFileSyncMock.mockReturnValueOnce("sk-browser-env\n"); // pragma: allowlist secret + fetchWithSsrFGuardMock.mockResolvedValueOnce({ + response: createJsonResponse({ + client_secret: { value: "client-secret-123" }, + }), + release: vi.fn(async () => undefined), + }); + const provider = buildOpenAIRealtimeVoiceProvider(); + if (!provider.createBrowserSession) { + throw new Error("expected OpenAI realtime provider to support browser sessions"); + } + + await provider.createBrowserSession({ + providerConfig: {}, + instructions: "Be concise.", + }); + + expect(execFileSyncMock).toHaveBeenCalledWith( + "/usr/bin/security", + ["find-generic-password", "-s", "openclaw", "-a", "OPENAI_REALTIME_BROWSER_TEST", "-w"], + expect.objectContaining({ + encoding: "utf8", + timeout: 5000, + }), + ); + expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( + expect.objectContaining({ + init: expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer sk-browser-env", // pragma: allowlist secret + }), + }), + }), + ); + }); + + it("resolves and caches keychain OPENAI_API_KEY refs before creating bridges", () => { + vi.stubEnv("OPENAI_API_KEY", "keychain:openclaw:OPENAI_REALTIME_BRIDGE_TEST"); + execFileSyncMock.mockReturnValue("sk-bridge-env\n"); // pragma: allowlist secret + const provider = buildOpenAIRealtimeVoiceProvider(); + + const first = provider.createBridge({ + providerConfig: {}, + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }); + const second = provider.createBridge({ + providerConfig: {}, + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }); + void first.connect(); + void second.connect(); + first.close(); + second.close(); + + expect(execFileSyncMock).toHaveBeenCalledTimes(1); + for (const socket of FakeWebSocket.instances) { + const options = socket.args[1] as { headers?: Record } | undefined; + expect(options?.headers).toMatchObject({ + Authorization: "Bearer sk-bridge-env", // pragma: allowlist secret + }); + } + }); + + it("does not resolve keychain refs during configured checks", () => { + vi.stubEnv("OPENAI_API_KEY", "keychain:openclaw:OPENAI_REALTIME_CONFIGURED_TEST"); + const provider = buildOpenAIRealtimeVoiceProvider(); + + expect(provider.isConfigured({ providerConfig: {} })).toBe(true); + expect(execFileSyncMock).not.toHaveBeenCalled(); + }); + + it("fails closed when keychain refs cannot be resolved", () => { + vi.stubEnv("OPENAI_API_KEY", "keychain:openclaw:OPENAI_REALTIME_MISSING_TEST"); + execFileSyncMock.mockImplementationOnce(() => { + throw new Error("keychain unavailable"); + }); + const provider = buildOpenAIRealtimeVoiceProvider(); + + expect(() => + provider.createBridge({ + providerConfig: {}, + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }), + ).toThrow("OpenAI API key missing"); + }); + it("normalizes provider-owned voice settings from raw provider config", () => { const provider = buildOpenAIRealtimeVoiceProvider(); const resolved = provider.resolveConfig?.({ diff --git a/extensions/openai/realtime-voice-provider.ts b/extensions/openai/realtime-voice-provider.ts index 9a4032d2904..e319e8b4b26 100644 --- a/extensions/openai/realtime-voice-provider.ts +++ b/extensions/openai/realtime-voice-provider.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { createProviderHttpError, @@ -20,7 +21,10 @@ import type { RealtimeVoiceTool, } from "openclaw/plugin-sdk/realtime-voice"; import { REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ } from "openclaw/plugin-sdk/realtime-voice"; -import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; +import { + normalizeResolvedSecretInputString, + normalizeSecretInputString, +} from "openclaw/plugin-sdk/secret-input"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import WebSocket from "ws"; import { @@ -128,6 +132,77 @@ function normalizeProviderConfig( }; } +type OpenAIRealtimeApiKeyResolution = + | { status: "available"; value: string } + | { status: "missing" }; + +const KEYCHAIN_SECRET_REF_RE = /^keychain:([^:]+):([^:]+)$/; +const KEYCHAIN_LOOKUP_TIMEOUT_MS = 5000; +const resolvedKeychainSecretRefCache = new Map(); + +function resolveKeychainSecretRef(value: string): string | undefined { + const trimmed = value.trim(); + const match = KEYCHAIN_SECRET_REF_RE.exec(trimmed); + if (!match) { + return trimmed || undefined; + } + const cached = resolvedKeychainSecretRefCache.get(trimmed); + if (cached) { + return cached; + } + const [, service, account] = match; + try { + const resolved = + execFileSync( + "/usr/bin/security", + ["find-generic-password", "-s", service, "-a", account, "-w"], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: KEYCHAIN_LOOKUP_TIMEOUT_MS, + }, + ).trim() || undefined; + if (resolved) { + resolvedKeychainSecretRefCache.set(trimmed, resolved); + } + return resolved; + } catch { + return undefined; + } +} + +function resolveOpenAIRealtimeApiKey( + configuredApiKey: string | undefined, +): OpenAIRealtimeApiKeyResolution { + const configured = normalizeSecretInputString(configuredApiKey); + if (configured) { + const value = resolveKeychainSecretRef(configured); + return value ? { status: "available", value } : { status: "missing" }; + } + + const envValue = normalizeSecretInputString(process.env.OPENAI_API_KEY); + if (!envValue) { + return { status: "missing" }; + } + const value = resolveKeychainSecretRef(envValue); + return value ? { status: "available", value } : { status: "missing" }; +} + +function requireOpenAIRealtimeApiKey(configuredApiKey: string | undefined): string { + const resolved = resolveOpenAIRealtimeApiKey(configuredApiKey); + if (resolved.status === "available") { + return resolved.value; + } + throw new Error("OpenAI API key missing"); +} + +function hasOpenAIRealtimeApiKeyInput(configuredApiKey: string | undefined): boolean { + return Boolean( + normalizeSecretInputString(configuredApiKey) ?? + normalizeSecretInputString(process.env.OPENAI_API_KEY), + ); +} + function base64ToBuffer(b64: string): Buffer { return Buffer.from(b64, "base64"); } @@ -666,10 +741,7 @@ async function createOpenAIRealtimeBrowserSession( req: RealtimeVoiceBrowserSessionCreateRequest, ): Promise { const config = normalizeProviderConfig(req.providerConfig); - const apiKey = config.apiKey || process.env.OPENAI_API_KEY; - if (!apiKey) { - throw new Error("OpenAI API key missing"); - } + const apiKey = requireOpenAIRealtimeApiKey(config.apiKey); if (config.azureEndpoint || config.azureDeployment) { throw new Error("OpenAI Realtime browser sessions do not support Azure endpoints yet"); } @@ -752,13 +824,10 @@ export function buildOpenAIRealtimeVoiceProvider(): RealtimeVoiceProviderPlugin autoSelectOrder: 10, resolveConfig: ({ rawConfig }) => normalizeProviderConfig(rawConfig), isConfigured: ({ providerConfig }) => - Boolean(normalizeProviderConfig(providerConfig).apiKey || process.env.OPENAI_API_KEY), + hasOpenAIRealtimeApiKeyInput(normalizeProviderConfig(providerConfig).apiKey), createBridge: (req) => { const config = normalizeProviderConfig(req.providerConfig); - const apiKey = config.apiKey || process.env.OPENAI_API_KEY; - if (!apiKey) { - throw new Error("OpenAI API key missing"); - } + const apiKey = requireOpenAIRealtimeApiKey(config.apiKey); return new OpenAIRealtimeVoiceBridge({ ...req, apiKey,