diff --git a/CHANGELOG.md b/CHANGELOG.md index 5129436d63a..f8816741333 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai - CLI/startup: read generated startup metadata from the bundled `dist` layout before falling back to live help rendering, so root/browser help and channel-option bootstrap stay on the fast path. Thanks @vincentkoc. - CLI/help: treat positional `help` invocations like `openclaw channels help` as help paths for startup gating, avoiding model/auth warmup while preserving positional arguments such as `openclaw docs help`. Thanks @gumadeiras. - Web search: route plugin-scoped web_search SecretRefs through the active runtime config snapshot so provider execution receives resolved credentials across app/runtime paths, including `plugins.entries.brave.config.webSearch.apiKey`. Fixes #68690. Thanks @VACInc. +- Voice Call: allow SecretRef-backed Twilio auth tokens and call-specific OpenAI/ElevenLabs TTS API keys through the plugin config surface. Fixes #68690. Thanks @joshavant. - Matrix/E2EE: stabilize recovery and broken-device QA flows while avoiding Matrix device-cleanup sync races that could leave shutdown-time crypto work running. Thanks @gumadeiras. - Cron: treat isolated run-level agent failures as job errors even when no reply payload is produced, synthesizing a safe error payload so model/provider failures increment error counters and trigger failure notifications instead of clearing as successful. Fixes #43604; carries forward #43631. Thanks @SPFAdvisors. - Cron: preserve exact `NO_REPLY` tool results from isolated jobs with empty final assistant turns as quiet successes instead of surfacing incomplete-turn errors. Fixes #68452; carries forward #68453. Thanks @anyech. diff --git a/docs/reference/secretref-credential-surface.md b/docs/reference/secretref-credential-surface.md index b0061d7b512..86e58cc269d 100644 --- a/docs/reference/secretref-credential-surface.md +++ b/docs/reference/secretref-credential-surface.md @@ -40,6 +40,7 @@ Scope intent: - `talk.providers.*.apiKey` - `messages.tts.providers.*.apiKey` - `tools.web.fetch.firecrawl.apiKey` +- `plugins.entries.acpx.config.mcpServers.*.env.*` - `plugins.entries.brave.config.webSearch.apiKey` - `plugins.entries.exa.config.webSearch.apiKey` - `plugins.entries.google.config.webSearch.apiKey` @@ -49,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.tts.providers.*.apiKey` +- `plugins.entries.voice-call.config.twilio.authToken` - `tools.web.search.apiKey` - `gateway.auth.password` - `gateway.auth.token` diff --git a/docs/reference/secretref-user-supplied-credentials-matrix.json b/docs/reference/secretref-user-supplied-credentials-matrix.json index f44221000f1..c5ac4c2dada 100644 --- a/docs/reference/secretref-user-supplied-credentials-matrix.json +++ b/docs/reference/secretref-user-supplied-credentials-matrix.json @@ -526,6 +526,13 @@ "secretShape": "secret_input", "optIn": true }, + { + "id": "plugins.entries.acpx.config.mcpServers.*.env.*", + "configFile": "openclaw.json", + "path": "plugins.entries.acpx.config.mcpServers.*.env.*", + "secretShape": "secret_input", + "optIn": true + }, { "id": "plugins.entries.brave.config.webSearch.apiKey", "configFile": "openclaw.json", @@ -582,6 +589,20 @@ "secretShape": "secret_input", "optIn": true }, + { + "id": "plugins.entries.voice-call.config.tts.providers.*.apiKey", + "configFile": "openclaw.json", + "path": "plugins.entries.voice-call.config.tts.providers.*.apiKey", + "secretShape": "secret_input", + "optIn": true + }, + { + "id": "plugins.entries.voice-call.config.twilio.authToken", + "configFile": "openclaw.json", + "path": "plugins.entries.voice-call.config.twilio.authToken", + "secretShape": "secret_input", + "optIn": true + }, { "id": "plugins.entries.xai.config.webSearch.apiKey", "configFile": "openclaw.json", diff --git a/extensions/voice-call/openclaw.plugin.json b/extensions/voice-call/openclaw.plugin.json index 943e7017ea9..04d98730378 100644 --- a/extensions/voice-call/openclaw.plugin.json +++ b/extensions/voice-call/openclaw.plugin.json @@ -203,7 +203,7 @@ "type": "string" }, "authToken": { - "type": "string" + "type": ["string", "object"] } } }, @@ -521,7 +521,7 @@ "additionalProperties": false, "properties": { "apiKey": { - "type": "string" + "type": ["string", "object"] }, "baseUrl": { "type": "string" @@ -547,7 +547,7 @@ "additionalProperties": false, "properties": { "apiKey": { - "type": "string" + "type": ["string", "object"] }, "baseUrl": { "type": "string" @@ -682,7 +682,7 @@ "type": "object", "properties": { "apiKey": { - "type": "string" + "type": ["string", "object"] } }, "additionalProperties": true @@ -718,6 +718,12 @@ } }, "configContracts": { - "compatibilityMigrationPaths": ["plugins.entries.voice-call.config"] + "compatibilityMigrationPaths": ["plugins.entries.voice-call.config"], + "secretInputs": { + "paths": [ + { "path": "twilio.authToken", "expected": "string" }, + { "path": "tts.providers.*.apiKey", "expected": "string" } + ] + } } } diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index af3f2600a8c..e17abb919c2 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -477,6 +477,38 @@ describe("config plugin validation", () => { expect(res.ok).toBe(true); }); + it("accepts voice-call SecretRef credentials declared by the plugin schema", async () => { + const res = validateInSuite({ + agents: { list: [{ id: "pi" }] }, + plugins: { + enabled: true, + load: { paths: [voiceCallSchemaPluginDir] }, + entries: { + "voice-call-schema-fixture": { + config: { + provider: "twilio", + twilio: { + accountSid: "twilio-account-sid-placeholder", + authToken: { source: "env", provider: "default", id: "TWILIO_AUTH_TOKEN" }, + }, + tts: { + providers: { + openai: { + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + }, + elevenlabs: { + apiKey: { source: "env", provider: "default", id: "ELEVENLABS_API_KEY" }, + }, + }, + }, + }, + }, + }, + }, + }); + expect(res.ok).toBe(true); + }); + it("rejects out-of-range voice-call OpenAI TTS speed values", async () => { const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, diff --git a/src/plugins/config-contracts.test.ts b/src/plugins/config-contracts.test.ts index 74dec84bd6f..599a6e46575 100644 --- a/src/plugins/config-contracts.test.ts +++ b/src/plugins/config-contracts.test.ts @@ -26,6 +26,8 @@ vi.mock("./plugin-registry.js", () => ({ import { resolvePluginConfigContractsById } from "./config-contracts.js"; +type PluginManifestRecord = PluginManifestRegistry["plugins"][number]; + function createRegistry(plugins: PluginManifestRegistry["plugins"]): PluginManifestRegistry { return { plugins, @@ -33,6 +35,46 @@ function createRegistry(plugins: PluginManifestRegistry["plugins"]): PluginManif }; } +function createPluginRecord( + overrides: Pick & Partial, +): PluginManifestRecord { + return { + rootDir: `/tmp/${overrides.id}`, + manifestPath: `/tmp/${overrides.id}/openclaw.plugin.json`, + channelConfigs: undefined, + providerAuthEnvVars: undefined, + configUiHints: undefined, + configSchema: undefined, + configContracts: undefined, + contracts: undefined, + name: undefined, + description: undefined, + version: undefined, + enabledByDefault: undefined, + autoEnableWhenConfiguredProviders: undefined, + legacyPluginIds: undefined, + format: undefined, + bundleFormat: undefined, + bundleCapabilities: undefined, + kind: undefined, + channels: [], + providers: [], + modelSupport: undefined, + cliBackends: [], + channelEnvVars: undefined, + providerAuthAliases: undefined, + providerAuthChoices: undefined, + skills: [], + settingsFiles: undefined, + hooks: [], + source: `/tmp/${overrides.id}/openclaw.plugin.json`, + setupSource: undefined, + startupDeferConfiguredChannelFullLoadUntilAfterListen: undefined, + channelCatalogMeta: undefined, + ...overrides, + }; +} + describe("resolvePluginConfigContractsById", () => { beforeEach(() => { mocks.findBundledPluginMetadataById.mockReset(); @@ -45,42 +87,10 @@ describe("resolvePluginConfigContractsById", () => { it("does not fall back to bundled metadata when registry already resolved a plugin without config contracts", () => { mocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue( createRegistry([ - { + createPluginRecord({ id: "brave", origin: "bundled", - rootDir: "/tmp/brave", - manifestPath: "/tmp/brave/openclaw.plugin.json", - channelConfigs: undefined, - providerAuthEnvVars: undefined, - configUiHints: undefined, - configSchema: undefined, - configContracts: undefined, - contracts: undefined, - name: undefined, - description: undefined, - version: undefined, - enabledByDefault: undefined, - autoEnableWhenConfiguredProviders: undefined, - legacyPluginIds: undefined, - format: undefined, - bundleFormat: undefined, - bundleCapabilities: undefined, - kind: undefined, - channels: [], - providers: [], - modelSupport: undefined, - cliBackends: [], - channelEnvVars: undefined, - providerAuthAliases: undefined, - providerAuthChoices: undefined, - skills: [], - settingsFiles: undefined, - hooks: [], - source: "/tmp/brave/openclaw.plugin.json", - setupSource: undefined, - startupDeferConfiguredChannelFullLoadUntilAfterListen: undefined, - channelCatalogMeta: undefined, - }, + }), ]), ); @@ -92,6 +102,92 @@ describe("resolvePluginConfigContractsById", () => { expect(mocks.findBundledPluginMetadataById).not.toHaveBeenCalled(); }); + it("can hydrate missing contracts from bundled metadata for resolved bundled plugins", () => { + mocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue( + createRegistry([ + createPluginRecord({ + id: "voice-call", + origin: "bundled", + configContracts: { + compatibilityMigrationPaths: ["plugins.entries.voice-call.config"], + }, + }), + ]), + ); + mocks.findBundledPluginMetadataById.mockReturnValue({ + manifest: { + configContracts: { + secretInputs: { + paths: [{ path: "twilio.authToken", 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" }], + }, + }, + }, + ], + ]), + ); + }); + + it("can hydrate missing contracts for plugin ids known to be bundled by runtime discovery", () => { + mocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue( + createRegistry([ + createPluginRecord({ + id: "voice-call", + origin: "config", + }), + ]), + ); + mocks.findBundledPluginMetadataById.mockReturnValue({ + manifest: { + configContracts: { + secretInputs: { + paths: [{ path: "tts.providers.*.apiKey", expected: "string" }], + }, + }, + }, + }); + + expect( + resolvePluginConfigContractsById({ + pluginIds: ["voice-call"], + fallbackBundledPluginIds: ["voice-call"], + }), + ).toEqual( + new Map([ + [ + "voice-call", + { + origin: "bundled", + configContracts: { + secretInputs: { + paths: [{ path: "tts.providers.*.apiKey", expected: "string" }], + }, + }, + }, + ], + ]), + ); + }); + it("can skip bundled metadata fallback for registry-scoped callers", () => { expect( resolvePluginConfigContractsById({ diff --git a/src/plugins/config-contracts.ts b/src/plugins/config-contracts.ts index 96e49f11496..83fc03bd3fc 100644 --- a/src/plugins/config-contracts.ts +++ b/src/plugins/config-contracts.ts @@ -103,6 +103,8 @@ export function resolvePluginConfigContractsById(params: { env?: NodeJS.ProcessEnv; cache?: boolean; fallbackToBundledMetadata?: boolean; + fallbackToBundledMetadataForResolvedBundled?: boolean; + fallbackBundledPluginIds?: readonly string[]; pluginIds: readonly string[]; }): ReadonlyMap { const matches = new Map(); @@ -112,8 +114,11 @@ export function resolvePluginConfigContractsById(params: { if (pluginIds.length === 0) { return matches; } + const fallbackBundledPluginIds = new Set( + (params.fallbackBundledPluginIds ?? []).map((pluginId) => pluginId.trim()).filter(Boolean), + ); - const resolvedPluginIds = new Set(); + const resolvedPluginOrigins = new Map(); const registry = loadPluginManifestRegistryForPluginRegistry({ config: params.config, workspaceDir: params.workspaceDir, @@ -125,7 +130,7 @@ export function resolvePluginConfigContractsById(params: { if (!pluginIds.includes(plugin.id)) { continue; } - resolvedPluginIds.add(plugin.id); + resolvedPluginOrigins.set(plugin.id, plugin.origin); if (!plugin.configContracts) { continue; } @@ -137,7 +142,35 @@ export function resolvePluginConfigContractsById(params: { if (params.fallbackToBundledMetadata ?? true) { for (const pluginId of pluginIds) { - if (matches.has(pluginId) || resolvedPluginIds.has(pluginId)) { + 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) { + matches.set(pluginId, { + origin: fallbackBundledPluginIds.has(pluginId) ? "bundled" : existing.origin, + configContracts: { + ...bundled.manifest.configContracts, + ...existing.configContracts, + secretInputs: bundled.manifest.configContracts.secretInputs, + }, + }); + } + continue; + } + if (matches.has(pluginId)) { + continue; + } + const resolvedOrigin = resolvedPluginOrigins.get(pluginId); + if ( + resolvedOrigin && + !(params.fallbackToBundledMetadataForResolvedBundled && resolvedOrigin === "bundled") && + !fallbackBundledPluginIds.has(pluginId) + ) { continue; } const bundled = findBundledPluginMetadataById(pluginId); diff --git a/src/secrets/exec-secret-ref-id-parity.test.ts b/src/secrets/exec-secret-ref-id-parity.test.ts index 7cd539dc9b1..44cb3ab48ac 100644 --- a/src/secrets/exec-secret-ref-id-parity.test.ts +++ b/src/secrets/exec-secret-ref-id-parity.test.ts @@ -125,6 +125,9 @@ describe("exec SecretRef id parity", () => { if (canonicalId.startsWith("tools.web.search.")) { return "tools.web.search"; } + if (canonicalId.startsWith("plugins.entries.")) { + return "plugins.config"; + } return "unclassified"; } diff --git a/src/secrets/runtime-config-collectors-plugins.bundled.test.ts b/src/secrets/runtime-config-collectors-plugins.bundled.test.ts new file mode 100644 index 00000000000..5bdd9c95499 --- /dev/null +++ b/src/secrets/runtime-config-collectors-plugins.bundled.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { findBundledPluginMetadataById } from "../plugins/bundled-plugin-metadata.js"; +import { resolvePluginConfigContractsById } from "../plugins/config-contracts.js"; +import { collectPluginConfigAssignments } from "./runtime-config-collectors-plugins.js"; +import { createResolverContext } from "./runtime-shared.js"; + +function envRef(id: string) { + return { source: "env" as const, provider: "default", id }; +} + +describe("collectPluginConfigAssignments bundled plugin manifests", () => { + it("collects voice-call SecretRef assignments from bundled manifest contracts", () => { + expect( + findBundledPluginMetadataById("voice-call")?.manifest.configContracts?.secretInputs?.paths, + ).toEqual([ + { path: "twilio.authToken", expected: "string" }, + { path: "tts.providers.*.apiKey", expected: "string" }, + ]); + const config = { + plugins: { + entries: { + "voice-call": { + enabled: true, + config: { + twilio: { + authToken: envRef("TWILIO_AUTH_TOKEN"), + }, + tts: { + providers: { + openai: { + apiKey: envRef("OPENAI_API_KEY"), + }, + elevenlabs: { + apiKey: envRef("ELEVENLABS_API_KEY"), + }, + }, + }, + }, + }, + }, + }, + } as OpenClawConfig; + expect( + resolvePluginConfigContractsById({ + config, + workspaceDir: resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)), + env: {}, + cache: true, + fallbackToBundledMetadata: true, + fallbackToBundledMetadataForResolvedBundled: true, + pluginIds: ["voice-call"], + fallbackBundledPluginIds: ["voice-call"], + }).get("voice-call")?.configContracts.secretInputs?.paths, + ).toEqual([ + { path: "twilio.authToken", expected: "string" }, + { path: "tts.providers.*.apiKey", expected: "string" }, + ]); + const context = createResolverContext({ + sourceConfig: config, + env: {}, + }); + + collectPluginConfigAssignments({ + config, + defaults: undefined, + context, + loadablePluginOrigins: new Map([["voice-call", "bundled"]]), + }); + + expect({ + assignments: context.assignments.map((assignment) => assignment.path).toSorted(), + warnings: context.warnings, + }).toEqual({ + assignments: [ + "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", + ], + warnings: [], + }); + }); +}); diff --git a/src/secrets/runtime-config-collectors-plugins.ts b/src/secrets/runtime-config-collectors-plugins.ts index d8d7022b760..72a8960e1a3 100644 --- a/src/secrets/runtime-config-collectors-plugins.ts +++ b/src/secrets/runtime-config-collectors-plugins.ts @@ -40,6 +40,9 @@ export function collectPluginConfigAssignments(params: { params.config, resolveDefaultAgentId(params.config), ); + const bundledLoadablePluginIds = [...(params.loadablePluginOrigins?.entries() ?? [])] + .filter(([, origin]) => origin === "bundled") + .map(([pluginId]) => pluginId); const pluginSecretInputs = new Map( [ ...resolvePluginConfigContractsById({ @@ -47,7 +50,9 @@ export function collectPluginConfigAssignments(params: { workspaceDir, env: params.context.env, cache: true, - fallbackToBundledMetadata: false, + fallbackToBundledMetadata: true, + fallbackToBundledMetadataForResolvedBundled: true, + fallbackBundledPluginIds: bundledLoadablePluginIds, pluginIds: Object.keys(entries), }).entries(), ].flatMap(([pluginId, metadata]) => { diff --git a/src/secrets/target-registry-data.ts b/src/secrets/target-registry-data.ts index f9e275c2df4..42af9f7a7e9 100644 --- a/src/secrets/target-registry-data.ts +++ b/src/secrets/target-registry-data.ts @@ -1,3 +1,4 @@ +import { listBundledPluginMetadata } from "../plugins/bundled-plugin-metadata.js"; import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; import { loadPluginManifestRegistryForPluginRegistry } from "../plugins/plugin-registry.js"; import { loadBundledChannelSecretContractApi } from "./channel-contract-api.js"; @@ -66,6 +67,30 @@ function listBundledWebProviderSecretTargetRegistryEntries(): SecretTargetRegist return entries.toSorted((left, right) => left.id.localeCompare(right.id)); } +function listBundledPluginConfigSecretTargetRegistryEntries(): SecretTargetRegistryEntry[] { + const entries: SecretTargetRegistryEntry[] = []; + const seen = new Set(); + for (const record of listBundledPluginMetadata({ + includeChannelConfigs: false, + includeSyntheticChannelConfigs: false, + })) { + const secretInputs = record.manifest.configContracts?.secretInputs?.paths ?? []; + for (const secretInput of secretInputs) { + const entry = createPluginOpenClawConfigSecretTargetEntry( + record.manifest.id, + secretInput.path, + ); + const key = `${entry.configFile}:${entry.pathPattern}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + entries.push(entry); + } + } + return entries.toSorted((left, right) => left.id.localeCompare(right.id)); +} + function listChannelSecretTargetRegistryEntries(): SecretTargetRegistryEntry[] { const entries: SecretTargetRegistryEntry[] = []; @@ -436,6 +461,7 @@ export function getSecretTargetRegistry(): SecretTargetRegistryEntry[] { cachedSecretTargetRegistry = [ ...CORE_SECRET_TARGET_REGISTRY, ...listBundledWebProviderSecretTargetRegistryEntries(), + ...listBundledPluginConfigSecretTargetRegistryEntries(), ...listChannelSecretTargetRegistryEntries(), ]; return cachedSecretTargetRegistry; diff --git a/src/secrets/target-registry.test.ts b/src/secrets/target-registry.test.ts index 9f93f28638e..2802a2bcad1 100644 --- a/src/secrets/target-registry.test.ts +++ b/src/secrets/target-registry.test.ts @@ -72,4 +72,23 @@ describe("secret target registry", () => { expect(fetchTarget).not.toBeNull(); expect(fetchTarget?.entry?.id).toBe("plugins.entries.firecrawl.config.webFetch.apiKey"); }); + + it("derives bundled plugin SecretInput contract target paths from plugin manifests", () => { + const coreTargetIds = new Set(getCoreSecretTargetRegistry().map((entry) => entry.id)); + expect(coreTargetIds.has("plugins.entries.voice-call.config.twilio.authToken")).toBe(false); + + const target = resolveConfigSecretTargetByPath([ + "plugins", + "entries", + "voice-call", + "config", + "tts", + "providers", + "elevenlabs", + "apiKey", + ]); + + expect(target).not.toBeNull(); + expect(target?.entry?.id).toBe("plugins.entries.voice-call.config.tts.providers.*.apiKey"); + }); });