mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:00:42 +00:00
fix(openai): resolve realtime keychain refs
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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?.({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user