fix(openai): resolve realtime keychain refs

This commit is contained in:
Peter Steinberger
2026-05-02 08:35:16 +01:00
parent d9f778fab3
commit bc77ab93ac
3 changed files with 186 additions and 12 deletions

View File

@@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Providers/OpenAI: resolve `keychain:<service>:<account>` `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.

View File

@@ -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<typeof import("node:child_process")>();
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<string, string> } | 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?.({

View File

@@ -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<string, string>();
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<RealtimeVoiceBrowserSession> {
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,