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 ### 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/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/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. - 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. return the exact missing provider configuration when used.
<Note> <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> </Note>
```json5 ```json5

View File

@@ -50,6 +50,8 @@ Scope intent:
- `plugins.entries.firecrawl.config.webSearch.apiKey` - `plugins.entries.firecrawl.config.webSearch.apiKey`
- `plugins.entries.minimax.config.webSearch.apiKey` - `plugins.entries.minimax.config.webSearch.apiKey`
- `plugins.entries.tavily.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.tts.providers.*.apiKey`
- `plugins.entries.voice-call.config.twilio.authToken` - `plugins.entries.voice-call.config.twilio.authToken`
- `tools.web.search.apiKey` - `tools.web.search.apiKey`

View File

@@ -589,6 +589,20 @@
"secretShape": "secret_input", "secretShape": "secret_input",
"optIn": true "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", "id": "plugins.entries.voice-call.config.tts.providers.*.apiKey",
"configFile": "openclaw.json", "configFile": "openclaw.json",

View File

@@ -109,6 +109,10 @@ function setup(config: Record<string, unknown>): Registered {
return { methods, tools, service }; return { methods, tools, service };
} }
function envRef(id: string) {
return { source: "env" as const, provider: "default", id };
}
async function registerVoiceCallCli( async function registerVoiceCallCli(
program: Command, program: Command,
pluginConfig: Record<string, unknown> = { provider: "mock" }, pluginConfig: Record<string, unknown> = { provider: "mock" },
@@ -275,6 +279,26 @@ describe("voice-call plugin", () => {
expect(noopLogger.warn).toHaveBeenCalledWith(expect.stringContaining("TWILIO_ACCOUNT_SID")); 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 () => { it("still reports missing provider setup when a command needs the runtime", async () => {
vi.stubEnv("TWILIO_ACCOUNT_SID", ""); vi.stubEnv("TWILIO_ACCOUNT_SID", "");
vi.stubEnv("TWILIO_AUTH_TOKEN", ""); vi.stubEnv("TWILIO_AUTH_TOKEN", "");

View File

@@ -727,6 +727,8 @@
"secretInputs": { "secretInputs": {
"paths": [ "paths": [
{ "path": "twilio.authToken", "expected": "string" }, { "path": "twilio.authToken", "expected": "string" },
{ "path": "realtime.providers.*.apiKey", "expected": "string" },
{ "path": "streaming.providers.*.apiKey", "expected": "string" },
{ "path": "tts.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 { afterEach, beforeEach, describe, expect, it } from "vitest";
import { import {
VoiceCallConfigSchema,
resolveTwilioAuthToken,
validateProviderConfig, validateProviderConfig,
normalizeVoiceCallConfig, normalizeVoiceCallConfig,
resolveVoiceCallConfig, resolveVoiceCallConfig,
@@ -11,6 +13,10 @@ function createBaseConfig(provider: "telnyx" | "twilio" | "plivo" | "mock"): Voi
return createVoiceCallBaseConfig({ provider }); return createVoiceCallBaseConfig({ provider });
} }
function envRef(id: string) {
return { source: "env" as const, provider: "default", id };
}
function requireElevenLabsTtsConfig(config: Pick<VoiceCallConfig, "tts">) { function requireElevenLabsTtsConfig(config: Pick<VoiceCallConfig, "tts">) {
const tts = config.tts; const tts = config.tts;
const elevenlabs = tts?.providers?.elevenlabs; const elevenlabs = tts?.providers?.elevenlabs;
@@ -80,6 +86,24 @@ describe("validateProviderConfig", () => {
}); });
describe("twilio provider", () => { 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", () => { it("passes validation with mixed config and env vars", () => {
process.env.TWILIO_AUTH_TOKEN = "secret"; process.env.TWILIO_AUTH_TOKEN = "secret";
let config = createBaseConfig("twilio"); let config = createBaseConfig("twilio");

View File

@@ -2,6 +2,12 @@ import {
REALTIME_VOICE_AGENT_CONSULT_TOOL_POLICIES, REALTIME_VOICE_AGENT_CONSULT_TOOL_POLICIES,
type RealtimeVoiceAgentConsultToolPolicy, type RealtimeVoiceAgentConsultToolPolicy,
} from "openclaw/plugin-sdk/realtime-voice"; } 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 { z } from "openclaw/plugin-sdk/zod";
import { TtsAutoSchema, TtsConfigSchema, TtsModeSchema, TtsProviderSchema } from "../api.js"; import { TtsAutoSchema, TtsConfigSchema, TtsModeSchema, TtsProviderSchema } from "../api.js";
import { deepMergeDefined } from "./deep-merge.js"; import { deepMergeDefined } from "./deep-merge.js";
@@ -39,6 +45,8 @@ export type InboundPolicy = z.infer<typeof InboundPolicySchema>;
// Provider-Specific Configuration // Provider-Specific Configuration
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
const SecretInputSchema = buildSecretInputSchema();
export const TelnyxConfigSchema = z export const TelnyxConfigSchema = z
.object({ .object({
/** Telnyx API v2 key */ /** Telnyx API v2 key */
@@ -56,10 +64,12 @@ export const TwilioConfigSchema = z
/** Twilio Account SID */ /** Twilio Account SID */
accountSid: z.string().min(1).optional(), accountSid: z.string().min(1).optional(),
/** Twilio Auth Token */ /** Twilio Auth Token */
authToken: z.string().min(1).optional(), authToken: SecretInputSchema.optional(),
}) })
.strict(); .strict();
export type TwilioConfig = z.infer<typeof TwilioConfigSchema>; export type TwilioConfig = Omit<z.infer<typeof TwilioConfigSchema>, "authToken"> & {
authToken?: SecretInput;
};
export const PlivoConfigSchema = z export const PlivoConfigSchema = z
.object({ .object({
@@ -393,13 +403,15 @@ export const VoiceCallConfigSchema = z
.strict(); .strict();
export type VoiceCallConfig = z.infer<typeof VoiceCallConfigSchema>; export type VoiceCallConfig = z.infer<typeof VoiceCallConfigSchema>;
type DeepPartial<T> = type DeepPartial<T> = T extends SecretInput
T extends Array<infer U> ? T
: T extends Array<infer U>
? DeepPartial<U>[] ? DeepPartial<U>[]
: T extends object : T extends object
? { [K in keyof T]?: DeepPartial<T[K]> } ? { [K in keyof T]?: DeepPartial<T[K]> }
: T; : T;
export type VoiceCallConfigInput = DeepPartial<VoiceCallConfig>; export type VoiceCallConfigInput = DeepPartial<VoiceCallConfig>;
const TWILIO_AUTH_TOKEN_PATH = "plugins.entries.voice-call.config.twilio.authToken";
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Configuration Helpers // 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 { export function normalizeVoiceCallConfig(config: VoiceCallConfigInput): VoiceCallConfig {
const defaults = cloneDefaultVoiceCallConfig(); const defaults = cloneDefaultVoiceCallConfig();
const serve = { ...defaults.serve, ...config.serve }; 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)", "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( errors.push(
"plugins.entries.voice-call.config.twilio.authToken is required (or set TWILIO_AUTH_TOKEN env)", "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 { setTimeout as sleep } from "node:timers/promises";
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime"; import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { TwilioConfig } from "../config.js";
import { getHeader } from "../http-headers.js"; import { getHeader } from "../http-headers.js";
import type { MediaStreamHandler } from "../media-stream.js"; import type { MediaStreamHandler } from "../media-stream.js";
import { chunkAudio } from "../telephony-audio.js"; import { chunkAudio } from "../telephony-audio.js";
@@ -69,6 +68,11 @@ type StreamSendResult = {
sent: boolean; sent: boolean;
}; };
type TwilioProviderConfig = {
accountSid?: string;
authToken?: string;
};
export class TwilioProvider implements VoiceCallProvider { export class TwilioProvider implements VoiceCallProvider {
readonly name = "twilio" as const; readonly name = "twilio" as const;
@@ -129,7 +133,7 @@ export class TwilioProvider implements VoiceCallProvider {
this.streamAuthTokens.delete(providerCallId); this.streamAuthTokens.delete(providerCallId);
} }
constructor(config: TwilioConfig, options: TwilioProviderOptions = {}) { constructor(config: TwilioProviderConfig, options: TwilioProviderOptions = {}) {
if (!config.accountSid) { if (!config.accountSid) {
throw new Error("Twilio Account SID is required"); throw new Error("Twilio Account SID is required");
} }

View File

@@ -6,6 +6,7 @@ import { createVoiceCallBaseConfig } from "./test-fixtures.js";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
resolveVoiceCallConfig: vi.fn(), resolveVoiceCallConfig: vi.fn(),
resolveTwilioAuthToken: vi.fn(),
validateProviderConfig: vi.fn(), validateProviderConfig: vi.fn(),
managerInitialize: vi.fn(), managerInitialize: vi.fn(),
managerGetCall: vi.fn(), managerGetCall: vi.fn(),
@@ -26,6 +27,7 @@ const mocks = vi.hoisted(() => ({
vi.mock("./config.js", () => ({ vi.mock("./config.js", () => ({
resolveVoiceCallConfig: mocks.resolveVoiceCallConfig, resolveVoiceCallConfig: mocks.resolveVoiceCallConfig,
resolveTwilioAuthToken: mocks.resolveTwilioAuthToken,
validateProviderConfig: mocks.validateProviderConfig, validateProviderConfig: mocks.validateProviderConfig,
})); }));
@@ -109,6 +111,9 @@ describe("createVoiceCallRuntime lifecycle", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mocks.resolveVoiceCallConfig.mockImplementation((cfg: VoiceCallConfig) => cfg); mocks.resolveVoiceCallConfig.mockImplementation((cfg: VoiceCallConfig) => cfg);
mocks.resolveTwilioAuthToken.mockImplementation(
(cfg: VoiceCallConfig) => cfg.twilio?.authToken,
);
mocks.validateProviderConfig.mockReturnValue({ valid: true, errors: [] }); mocks.validateProviderConfig.mockReturnValue({ valid: true, errors: [] });
mocks.managerInitialize.mockResolvedValue(undefined); mocks.managerInitialize.mockResolvedValue(undefined);
mocks.managerGetCall.mockReset(); mocks.managerGetCall.mockReset();

View File

@@ -9,7 +9,11 @@ import {
type ResolvedRealtimeVoiceProvider, type ResolvedRealtimeVoiceProvider,
} from "openclaw/plugin-sdk/realtime-voice"; } from "openclaw/plugin-sdk/realtime-voice";
import type { VoiceCallConfig } from "./config.js"; 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 type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
import { CallManager } from "./manager.js"; import { CallManager } from "./manager.js";
import type { VoiceCallProvider } from "./providers/base.js"; import type { VoiceCallProvider } from "./providers/base.js";
@@ -195,7 +199,7 @@ async function resolveProvider(config: VoiceCallConfig): Promise<VoiceCallProvid
return new TwilioProvider( return new TwilioProvider(
{ {
accountSid: config.twilio?.accountSid, accountSid: config.twilio?.accountSid,
authToken: config.twilio?.authToken, authToken: resolveTwilioAuthToken(config),
}, },
{ {
allowNgrokFreeTierLoopbackBypass, 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", () => { it("can hydrate missing contracts for plugin ids known to be bundled by runtime discovery", () => {
mocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue( mocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue(
createRegistry([ createRegistry([

View File

@@ -143,18 +143,19 @@ export function resolvePluginConfigContractsById(params: {
const existing = matches.get(pluginId); const existing = matches.get(pluginId);
const shouldHydrateBundledMatch = const shouldHydrateBundledMatch =
existing && existing &&
!existing.configContracts.secretInputs &&
((params.fallbackToBundledMetadataForResolvedBundled && existing.origin === "bundled") || ((params.fallbackToBundledMetadataForResolvedBundled && existing.origin === "bundled") ||
fallbackBundledPluginIds.has(pluginId)); fallbackBundledPluginIds.has(pluginId));
if (shouldHydrateBundledMatch) { if (shouldHydrateBundledMatch) {
const bundled = findBundledPluginMetadataById(pluginId); const bundled = findBundledPluginMetadataById(pluginId);
if (bundled?.manifest.configContracts?.secretInputs) { if (bundled?.manifest.configContracts) {
matches.set(pluginId, { matches.set(pluginId, {
origin: fallbackBundledPluginIds.has(pluginId) ? "bundled" : existing.origin, origin: fallbackBundledPluginIds.has(pluginId) ? "bundled" : existing.origin,
configContracts: { configContracts: {
...bundled.manifest.configContracts, ...bundled.manifest.configContracts,
...existing.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, findBundledPluginMetadataById("voice-call")?.manifest.configContracts?.secretInputs?.paths,
).toEqual([ ).toEqual([
{ path: "twilio.authToken", expected: "string" }, { path: "twilio.authToken", expected: "string" },
{ path: "realtime.providers.*.apiKey", expected: "string" },
{ path: "streaming.providers.*.apiKey", expected: "string" },
{ path: "tts.providers.*.apiKey", expected: "string" }, { path: "tts.providers.*.apiKey", expected: "string" },
]); ]);
const config = { const config = {
@@ -27,6 +29,20 @@ describe("collectPluginConfigAssignments bundled plugin manifests", () => {
twilio: { twilio: {
authToken: envRef("TWILIO_AUTH_TOKEN"), authToken: envRef("TWILIO_AUTH_TOKEN"),
}, },
realtime: {
providers: {
google: {
apiKey: envRef("GEMINI_API_KEY"),
},
},
},
streaming: {
providers: {
openai: {
apiKey: envRef("OPENAI_API_KEY"),
},
},
},
tts: { tts: {
providers: { providers: {
openai: { openai: {
@@ -54,6 +70,8 @@ describe("collectPluginConfigAssignments bundled plugin manifests", () => {
}).get("voice-call")?.configContracts.secretInputs?.paths, }).get("voice-call")?.configContracts.secretInputs?.paths,
).toEqual([ ).toEqual([
{ path: "twilio.authToken", expected: "string" }, { path: "twilio.authToken", expected: "string" },
{ path: "realtime.providers.*.apiKey", expected: "string" },
{ path: "streaming.providers.*.apiKey", expected: "string" },
{ path: "tts.providers.*.apiKey", expected: "string" }, { path: "tts.providers.*.apiKey", expected: "string" },
]); ]);
const context = createResolverContext({ const context = createResolverContext({
@@ -73,6 +91,8 @@ describe("collectPluginConfigAssignments bundled plugin manifests", () => {
warnings: context.warnings, warnings: context.warnings,
}).toEqual({ }).toEqual({
assignments: [ 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.elevenlabs.apiKey",
"plugins.entries.voice-call.config.tts.providers.openai.apiKey", "plugins.entries.voice-call.config.tts.providers.openai.apiKey",
"plugins.entries.voice-call.config.twilio.authToken", "plugins.entries.voice-call.config.twilio.authToken",