fix: resolve voice-call SecretRef inputs (#73632)

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
VACInc
2026-05-01 02:21:02 -04:00
committed by GitHub
parent ec1b96cdfa
commit be14820b5d
14 changed files with 189 additions and 13 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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`

View File

@@ -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",

View File

@@ -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", "");

View File

@@ -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" }
]
}

View File

@@ -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");

View File

@@ -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)",
);

View File

@@ -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");
}

View File

@@ -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();

View File

@@ -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,

View File

@@ -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([

View File

@@ -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 }
: {}),
},
});
}

View File

@@ -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",