mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
fix: resolve voice-call SecretRef inputs (#73632)
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -111,6 +111,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Voice Call: resolve SecretRef-backed Twilio auth tokens and realtime/streaming provider API keys before initializing call providers, so SecretRef-backed voice-call credentials reach runtime as strings. (#73632) Thanks @VACInc.
|
||||
- Security/outbound: strip re-formed HTML tags during plain-text sanitization so nested tag fragments cannot leave a CodeQL-detected `<script>` sequence behind. Thanks @vincentkoc.
|
||||
- Security/secrets: compare credential bytes with padded timing-safe buffers instead of hashing candidate passwords before equality checks. Thanks @vincentkoc.
|
||||
- Security/QQBot: sanitize debug log arguments before writing to `console.*`, so gateway payload fields cannot forge extra log lines when debug logging is enabled. Thanks @vincentkoc.
|
||||
|
||||
@@ -96,7 +96,7 @@ skips starting the runtime. Commands, RPC calls, and agent tools still
|
||||
return the exact missing provider configuration when used.
|
||||
|
||||
<Note>
|
||||
Voice-call credentials accept SecretRefs. `plugins.entries.voice-call.config.twilio.authToken` and `plugins.entries.voice-call.config.tts.providers.*.apiKey` resolve through the standard SecretRef surface; see [SecretRef credential surface](/reference/secretref-credential-surface).
|
||||
Voice-call credentials accept SecretRefs. `plugins.entries.voice-call.config.twilio.authToken`, `plugins.entries.voice-call.config.realtime.providers.*.apiKey`, `plugins.entries.voice-call.config.streaming.providers.*.apiKey`, and `plugins.entries.voice-call.config.tts.providers.*.apiKey` resolve through the standard SecretRef surface; see [SecretRef credential surface](/reference/secretref-credential-surface).
|
||||
</Note>
|
||||
|
||||
```json5
|
||||
|
||||
@@ -50,6 +50,8 @@ Scope intent:
|
||||
- `plugins.entries.firecrawl.config.webSearch.apiKey`
|
||||
- `plugins.entries.minimax.config.webSearch.apiKey`
|
||||
- `plugins.entries.tavily.config.webSearch.apiKey`
|
||||
- `plugins.entries.voice-call.config.realtime.providers.*.apiKey`
|
||||
- `plugins.entries.voice-call.config.streaming.providers.*.apiKey`
|
||||
- `plugins.entries.voice-call.config.tts.providers.*.apiKey`
|
||||
- `plugins.entries.voice-call.config.twilio.authToken`
|
||||
- `tools.web.search.apiKey`
|
||||
|
||||
@@ -589,6 +589,20 @@
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "plugins.entries.voice-call.config.realtime.providers.*.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "plugins.entries.voice-call.config.realtime.providers.*.apiKey",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "plugins.entries.voice-call.config.streaming.providers.*.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "plugins.entries.voice-call.config.streaming.providers.*.apiKey",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "plugins.entries.voice-call.config.tts.providers.*.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
|
||||
@@ -109,6 +109,10 @@ function setup(config: Record<string, unknown>): Registered {
|
||||
return { methods, tools, service };
|
||||
}
|
||||
|
||||
function envRef(id: string) {
|
||||
return { source: "env" as const, provider: "default", id };
|
||||
}
|
||||
|
||||
async function registerVoiceCallCli(
|
||||
program: Command,
|
||||
pluginConfig: Record<string, unknown> = { provider: "mock" },
|
||||
@@ -275,6 +279,26 @@ describe("voice-call plugin", () => {
|
||||
expect(noopLogger.warn).toHaveBeenCalledWith(expect.stringContaining("TWILIO_ACCOUNT_SID"));
|
||||
});
|
||||
|
||||
it("registers Twilio configs with SecretRef auth tokens", async () => {
|
||||
const authToken = envRef("TWILIO_AUTH_TOKEN");
|
||||
const { service } = setup({
|
||||
enabled: true,
|
||||
provider: "twilio",
|
||||
fromNumber: "+15550001234",
|
||||
twilio: {
|
||||
accountSid: "AC123",
|
||||
authToken,
|
||||
},
|
||||
});
|
||||
|
||||
await service?.start(createServiceContext());
|
||||
|
||||
expect(createVoiceCallRuntime).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(createVoiceCallRuntime).mock.calls[0]?.[0]?.config.twilio?.authToken).toEqual(
|
||||
authToken,
|
||||
);
|
||||
});
|
||||
|
||||
it("still reports missing provider setup when a command needs the runtime", async () => {
|
||||
vi.stubEnv("TWILIO_ACCOUNT_SID", "");
|
||||
vi.stubEnv("TWILIO_AUTH_TOKEN", "");
|
||||
|
||||
@@ -727,6 +727,8 @@
|
||||
"secretInputs": {
|
||||
"paths": [
|
||||
{ "path": "twilio.authToken", "expected": "string" },
|
||||
{ "path": "realtime.providers.*.apiKey", "expected": "string" },
|
||||
{ "path": "streaming.providers.*.apiKey", "expected": "string" },
|
||||
{ "path": "tts.providers.*.apiKey", "expected": "string" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
VoiceCallConfigSchema,
|
||||
resolveTwilioAuthToken,
|
||||
validateProviderConfig,
|
||||
normalizeVoiceCallConfig,
|
||||
resolveVoiceCallConfig,
|
||||
@@ -11,6 +13,10 @@ function createBaseConfig(provider: "telnyx" | "twilio" | "plivo" | "mock"): Voi
|
||||
return createVoiceCallBaseConfig({ provider });
|
||||
}
|
||||
|
||||
function envRef(id: string) {
|
||||
return { source: "env" as const, provider: "default", id };
|
||||
}
|
||||
|
||||
function requireElevenLabsTtsConfig(config: Pick<VoiceCallConfig, "tts">) {
|
||||
const tts = config.tts;
|
||||
const elevenlabs = tts?.providers?.elevenlabs;
|
||||
@@ -80,6 +86,24 @@ describe("validateProviderConfig", () => {
|
||||
});
|
||||
|
||||
describe("twilio provider", () => {
|
||||
it("accepts SecretRef-backed auth tokens before runtime resolution", () => {
|
||||
const config = VoiceCallConfigSchema.parse({
|
||||
enabled: true,
|
||||
provider: "twilio",
|
||||
fromNumber: "+15550001234",
|
||||
twilio: {
|
||||
accountSid: "AC123",
|
||||
authToken: envRef("TWILIO_AUTH_TOKEN"),
|
||||
},
|
||||
});
|
||||
|
||||
expect(config.twilio?.authToken).toEqual(envRef("TWILIO_AUTH_TOKEN"));
|
||||
expect(validateProviderConfig(config)).toMatchObject({ valid: true, errors: [] });
|
||||
expect(() => resolveTwilioAuthToken(config)).toThrow(
|
||||
'plugins.entries.voice-call.config.twilio.authToken: unresolved SecretRef "env:default:TWILIO_AUTH_TOKEN"',
|
||||
);
|
||||
});
|
||||
|
||||
it("passes validation with mixed config and env vars", () => {
|
||||
process.env.TWILIO_AUTH_TOKEN = "secret";
|
||||
let config = createBaseConfig("twilio");
|
||||
|
||||
@@ -2,6 +2,12 @@ import {
|
||||
REALTIME_VOICE_AGENT_CONSULT_TOOL_POLICIES,
|
||||
type RealtimeVoiceAgentConsultToolPolicy,
|
||||
} from "openclaw/plugin-sdk/realtime-voice";
|
||||
import {
|
||||
buildSecretInputSchema,
|
||||
hasConfiguredSecretInput,
|
||||
normalizeResolvedSecretInputString,
|
||||
type SecretInput,
|
||||
} from "openclaw/plugin-sdk/secret-input";
|
||||
import { z } from "openclaw/plugin-sdk/zod";
|
||||
import { TtsAutoSchema, TtsConfigSchema, TtsModeSchema, TtsProviderSchema } from "../api.js";
|
||||
import { deepMergeDefined } from "./deep-merge.js";
|
||||
@@ -39,6 +45,8 @@ export type InboundPolicy = z.infer<typeof InboundPolicySchema>;
|
||||
// Provider-Specific Configuration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const SecretInputSchema = buildSecretInputSchema();
|
||||
|
||||
export const TelnyxConfigSchema = z
|
||||
.object({
|
||||
/** Telnyx API v2 key */
|
||||
@@ -56,10 +64,12 @@ export const TwilioConfigSchema = z
|
||||
/** Twilio Account SID */
|
||||
accountSid: z.string().min(1).optional(),
|
||||
/** Twilio Auth Token */
|
||||
authToken: z.string().min(1).optional(),
|
||||
authToken: SecretInputSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
export type TwilioConfig = z.infer<typeof TwilioConfigSchema>;
|
||||
export type TwilioConfig = Omit<z.infer<typeof TwilioConfigSchema>, "authToken"> & {
|
||||
authToken?: SecretInput;
|
||||
};
|
||||
|
||||
export const PlivoConfigSchema = z
|
||||
.object({
|
||||
@@ -393,13 +403,15 @@ export const VoiceCallConfigSchema = z
|
||||
.strict();
|
||||
|
||||
export type VoiceCallConfig = z.infer<typeof VoiceCallConfigSchema>;
|
||||
type DeepPartial<T> =
|
||||
T extends Array<infer U>
|
||||
type DeepPartial<T> = T extends SecretInput
|
||||
? T
|
||||
: T extends Array<infer U>
|
||||
? DeepPartial<U>[]
|
||||
: T extends object
|
||||
? { [K in keyof T]?: DeepPartial<T[K]> }
|
||||
: T;
|
||||
export type VoiceCallConfigInput = DeepPartial<VoiceCallConfig>;
|
||||
const TWILIO_AUTH_TOKEN_PATH = "plugins.entries.voice-call.config.twilio.authToken";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Configuration Helpers
|
||||
@@ -458,6 +470,15 @@ function sanitizeVoiceCallProviderConfigs(
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveTwilioAuthToken(
|
||||
config: Pick<VoiceCallConfig, "twilio">,
|
||||
): string | undefined {
|
||||
return normalizeResolvedSecretInputString({
|
||||
value: config.twilio?.authToken,
|
||||
path: TWILIO_AUTH_TOKEN_PATH,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeVoiceCallConfig(config: VoiceCallConfigInput): VoiceCallConfig {
|
||||
const defaults = cloneDefaultVoiceCallConfig();
|
||||
const serve = { ...defaults.serve, ...config.serve };
|
||||
@@ -608,7 +629,7 @@ export function validateProviderConfig(config: VoiceCallConfig): {
|
||||
"plugins.entries.voice-call.config.twilio.accountSid is required (or set TWILIO_ACCOUNT_SID env)",
|
||||
);
|
||||
}
|
||||
if (!config.twilio?.authToken) {
|
||||
if (!hasConfiguredSecretInput(config.twilio?.authToken)) {
|
||||
errors.push(
|
||||
"plugins.entries.voice-call.config.twilio.authToken is required (or set TWILIO_AUTH_TOKEN env)",
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ import crypto from "node:crypto";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { TwilioConfig } from "../config.js";
|
||||
import { getHeader } from "../http-headers.js";
|
||||
import type { MediaStreamHandler } from "../media-stream.js";
|
||||
import { chunkAudio } from "../telephony-audio.js";
|
||||
@@ -69,6 +68,11 @@ type StreamSendResult = {
|
||||
sent: boolean;
|
||||
};
|
||||
|
||||
type TwilioProviderConfig = {
|
||||
accountSid?: string;
|
||||
authToken?: string;
|
||||
};
|
||||
|
||||
export class TwilioProvider implements VoiceCallProvider {
|
||||
readonly name = "twilio" as const;
|
||||
|
||||
@@ -129,7 +133,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
this.streamAuthTokens.delete(providerCallId);
|
||||
}
|
||||
|
||||
constructor(config: TwilioConfig, options: TwilioProviderOptions = {}) {
|
||||
constructor(config: TwilioProviderConfig, options: TwilioProviderOptions = {}) {
|
||||
if (!config.accountSid) {
|
||||
throw new Error("Twilio Account SID is required");
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createVoiceCallBaseConfig } from "./test-fixtures.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolveVoiceCallConfig: vi.fn(),
|
||||
resolveTwilioAuthToken: vi.fn(),
|
||||
validateProviderConfig: vi.fn(),
|
||||
managerInitialize: vi.fn(),
|
||||
managerGetCall: vi.fn(),
|
||||
@@ -26,6 +27,7 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("./config.js", () => ({
|
||||
resolveVoiceCallConfig: mocks.resolveVoiceCallConfig,
|
||||
resolveTwilioAuthToken: mocks.resolveTwilioAuthToken,
|
||||
validateProviderConfig: mocks.validateProviderConfig,
|
||||
}));
|
||||
|
||||
@@ -109,6 +111,9 @@ describe("createVoiceCallRuntime lifecycle", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.resolveVoiceCallConfig.mockImplementation((cfg: VoiceCallConfig) => cfg);
|
||||
mocks.resolveTwilioAuthToken.mockImplementation(
|
||||
(cfg: VoiceCallConfig) => cfg.twilio?.authToken,
|
||||
);
|
||||
mocks.validateProviderConfig.mockReturnValue({ valid: true, errors: [] });
|
||||
mocks.managerInitialize.mockResolvedValue(undefined);
|
||||
mocks.managerGetCall.mockReset();
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
type ResolvedRealtimeVoiceProvider,
|
||||
} from "openclaw/plugin-sdk/realtime-voice";
|
||||
import type { VoiceCallConfig } from "./config.js";
|
||||
import { resolveVoiceCallConfig, validateProviderConfig } from "./config.js";
|
||||
import {
|
||||
resolveTwilioAuthToken,
|
||||
resolveVoiceCallConfig,
|
||||
validateProviderConfig,
|
||||
} from "./config.js";
|
||||
import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
|
||||
import { CallManager } from "./manager.js";
|
||||
import type { VoiceCallProvider } from "./providers/base.js";
|
||||
@@ -195,7 +199,7 @@ async function resolveProvider(config: VoiceCallConfig): Promise<VoiceCallProvid
|
||||
return new TwilioProvider(
|
||||
{
|
||||
accountSid: config.twilio?.accountSid,
|
||||
authToken: config.twilio?.authToken,
|
||||
authToken: resolveTwilioAuthToken(config),
|
||||
},
|
||||
{
|
||||
allowNgrokFreeTierLoopbackBypass,
|
||||
|
||||
@@ -147,6 +147,60 @@ describe("resolvePluginConfigContractsById", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes stale bundled SecretInput contracts from bundled metadata", () => {
|
||||
mocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue(
|
||||
createRegistry([
|
||||
createPluginRecord({
|
||||
id: "voice-call",
|
||||
origin: "bundled",
|
||||
configContracts: {
|
||||
compatibilityMigrationPaths: ["plugins.entries.voice-call.config"],
|
||||
secretInputs: {
|
||||
paths: [{ path: "twilio.authToken", expected: "string" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
]),
|
||||
);
|
||||
mocks.findBundledPluginMetadataById.mockReturnValue({
|
||||
manifest: {
|
||||
configContracts: {
|
||||
secretInputs: {
|
||||
paths: [
|
||||
{ path: "twilio.authToken", expected: "string" },
|
||||
{ path: "realtime.providers.*.apiKey", expected: "string" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
resolvePluginConfigContractsById({
|
||||
pluginIds: ["voice-call"],
|
||||
fallbackToBundledMetadataForResolvedBundled: true,
|
||||
}),
|
||||
).toEqual(
|
||||
new Map([
|
||||
[
|
||||
"voice-call",
|
||||
{
|
||||
origin: "bundled",
|
||||
configContracts: {
|
||||
compatibilityMigrationPaths: ["plugins.entries.voice-call.config"],
|
||||
secretInputs: {
|
||||
paths: [
|
||||
{ path: "twilio.authToken", expected: "string" },
|
||||
{ path: "realtime.providers.*.apiKey", expected: "string" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("can hydrate missing contracts for plugin ids known to be bundled by runtime discovery", () => {
|
||||
mocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue(
|
||||
createRegistry([
|
||||
|
||||
@@ -143,18 +143,19 @@ export function resolvePluginConfigContractsById(params: {
|
||||
const existing = matches.get(pluginId);
|
||||
const shouldHydrateBundledMatch =
|
||||
existing &&
|
||||
!existing.configContracts.secretInputs &&
|
||||
((params.fallbackToBundledMetadataForResolvedBundled && existing.origin === "bundled") ||
|
||||
fallbackBundledPluginIds.has(pluginId));
|
||||
if (shouldHydrateBundledMatch) {
|
||||
const bundled = findBundledPluginMetadataById(pluginId);
|
||||
if (bundled?.manifest.configContracts?.secretInputs) {
|
||||
if (bundled?.manifest.configContracts) {
|
||||
matches.set(pluginId, {
|
||||
origin: fallbackBundledPluginIds.has(pluginId) ? "bundled" : existing.origin,
|
||||
configContracts: {
|
||||
...bundled.manifest.configContracts,
|
||||
...existing.configContracts,
|
||||
secretInputs: bundled.manifest.configContracts.secretInputs,
|
||||
...(bundled.manifest.configContracts.secretInputs
|
||||
? { secretInputs: bundled.manifest.configContracts.secretInputs }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ describe("collectPluginConfigAssignments bundled plugin manifests", () => {
|
||||
findBundledPluginMetadataById("voice-call")?.manifest.configContracts?.secretInputs?.paths,
|
||||
).toEqual([
|
||||
{ path: "twilio.authToken", expected: "string" },
|
||||
{ path: "realtime.providers.*.apiKey", expected: "string" },
|
||||
{ path: "streaming.providers.*.apiKey", expected: "string" },
|
||||
{ path: "tts.providers.*.apiKey", expected: "string" },
|
||||
]);
|
||||
const config = {
|
||||
@@ -27,6 +29,20 @@ describe("collectPluginConfigAssignments bundled plugin manifests", () => {
|
||||
twilio: {
|
||||
authToken: envRef("TWILIO_AUTH_TOKEN"),
|
||||
},
|
||||
realtime: {
|
||||
providers: {
|
||||
google: {
|
||||
apiKey: envRef("GEMINI_API_KEY"),
|
||||
},
|
||||
},
|
||||
},
|
||||
streaming: {
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: envRef("OPENAI_API_KEY"),
|
||||
},
|
||||
},
|
||||
},
|
||||
tts: {
|
||||
providers: {
|
||||
openai: {
|
||||
@@ -54,6 +70,8 @@ describe("collectPluginConfigAssignments bundled plugin manifests", () => {
|
||||
}).get("voice-call")?.configContracts.secretInputs?.paths,
|
||||
).toEqual([
|
||||
{ path: "twilio.authToken", expected: "string" },
|
||||
{ path: "realtime.providers.*.apiKey", expected: "string" },
|
||||
{ path: "streaming.providers.*.apiKey", expected: "string" },
|
||||
{ path: "tts.providers.*.apiKey", expected: "string" },
|
||||
]);
|
||||
const context = createResolverContext({
|
||||
@@ -73,6 +91,8 @@ describe("collectPluginConfigAssignments bundled plugin manifests", () => {
|
||||
warnings: context.warnings,
|
||||
}).toEqual({
|
||||
assignments: [
|
||||
"plugins.entries.voice-call.config.realtime.providers.google.apiKey",
|
||||
"plugins.entries.voice-call.config.streaming.providers.openai.apiKey",
|
||||
"plugins.entries.voice-call.config.tts.providers.elevenlabs.apiKey",
|
||||
"plugins.entries.voice-call.config.tts.providers.openai.apiKey",
|
||||
"plugins.entries.voice-call.config.twilio.authToken",
|
||||
|
||||
Reference in New Issue
Block a user