From 526372ea36e3f74ce720cc6a7f9ca0231be62a70 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 06:05:27 +0100 Subject: [PATCH] fix(gateway): use runtime config for secret-backed talk * fix(gateway): use runtime config for secret-backed talk * test(gateway): relax talk config rpc timeout * refactor(gateway): clarify talk config resolution --- CHANGELOG.md | 2 +- docs/plugins/sdk-runtime.md | 2 + extensions/speech-core/src/tts.ts | 42 ++----- .../server-methods/channels.start.test.ts | 76 ++++++++++++- src/gateway/server-methods/channels.ts | 2 +- src/gateway/server-methods/talk.test.ts | 106 ++++++++++++++++++ src/gateway/server-methods/talk.ts | 55 ++++----- src/gateway/server.talk-config.test.ts | 95 +--------------- src/plugin-sdk/runtime-config-snapshot.ts | 1 + 9 files changed, 215 insertions(+), 166 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2af83d20cc4..4559b929d38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,7 +72,7 @@ Docs: https://docs.openclaw.ai - Backup: skip installed plugin `extensions/*/node_modules` dependency trees while keeping plugin manifests and source files in archives, so local backups avoid rebuildable npm payload bloat. Fixes #64144. Thanks @BrilliantWang. - Cron/models: fail isolated cron runs closed when an explicit `payload.model` is not allowed or cannot be resolved, so scheduled jobs do not silently fall back to an unrelated agent default or paid route before configured provider proxies such as LiteLLM can run. Fixes #73146. Thanks @oneandrewwang. - Memory/QMD: back off repeated chat-turn QMD open failures while still letting memory status and CLI probes recheck immediately, so a broken sidecar dependency cannot trigger active-memory or cron retry storms. Fixes #73188 and #73176. Thanks @leonlushgit and @w3i-William. -- Talk Mode: keep `talk.config` callable when `messages.tts.providers.` stores SecretRef-backed `apiKey` or `token` values, so Talk overlays can discover the configured speech provider without falling back to local speech. Fixes #73109. (#73111) Thanks @omarshahine. +- Talk Mode: resolve `messages.tts.providers..apiKey` through the active runtime snapshot for `talk.config`, so Talk overlays can discover SecretRef-backed speech providers without falling back to local speech. Fixes #73109. (#73111) Thanks @omarshahine. - Memory/Ollama: resolve `memorySearch.provider` custom provider ids through their configured `models.providers..api` owner, so multi-GPU Ollama setups can dedicate embeddings to providers such as `ollama-5080` without losing the Ollama adapter or local auth semantics. Fixes #73150. Thanks @oneandrewwang. - CLI/memory: skip eager context-window warmup for `openclaw memory` commands so memory search does not race unrelated model metadata discovery. Fixes #73123. Thanks @oalansilva and @neeravmakwana. - CLI/Telegram: route Telegram `message send` and poll actions through the running Gateway when available, so packaged installs use the staged `grammy` runtime deps and CLI sends return instead of hanging after the Telegram channel is active. Fixes #73140. Thanks @oalansilva. diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md index 63cc530c34e..fbf4c46f4b5 100644 --- a/docs/plugins/sdk-runtime.md +++ b/docs/plugins/sdk-runtime.md @@ -52,6 +52,8 @@ subpaths directly instead of mocking the broad compatibility barrel. Internal OpenClaw runtime code has the same direction: load config once at the CLI, gateway, or process boundary, then pass that value through. Successful mutation writes refresh the process runtime snapshot and advance its internal revision; long-lived caches should key off the runtime-owned cache key instead of serializing config locally. Long-lived runtime modules have a zero-tolerance scanner for ambient `loadConfig()` calls; use a passed `cfg`, a request `context.getRuntimeConfig()`, or `getRuntimeConfig()` at an explicit process boundary. +Provider and channel execution paths must use the active runtime config snapshot, not a file snapshot returned for config readback or editing. File snapshots preserve source values such as SecretRef markers for UI and writes; provider callbacks need the resolved runtime view. When a helper may be called with either the active source snapshot or the active runtime snapshot, route through `selectApplicableRuntimeConfig()` before reading credentials. + ## Runtime namespaces diff --git a/extensions/speech-core/src/tts.ts b/extensions/speech-core/src/tts.ts index eb821d2f015..b975db5c972 100644 --- a/extensions/speech-core/src/tts.ts +++ b/extensions/speech-core/src/tts.ts @@ -27,6 +27,7 @@ import { import { getRuntimeConfigSnapshot, getRuntimeConfigSourceSnapshot, + selectApplicableRuntimeConfig, } from "openclaw/plugin-sdk/runtime-config-snapshot"; import { isVerbose, logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/sandbox"; @@ -235,41 +236,14 @@ function _resolveRegistryDefaultSpeechProviderId(cfg?: OpenClawConfig): TtsProvi return sortSpeechProvidersForAutoSelection(cfg)[0]?.id ?? ""; } -function stableConfigStringify(value: unknown): string { - if (value === null || typeof value !== "object") { - return JSON.stringify(value) ?? "null"; - } - if (Array.isArray(value)) { - return `[${value.map((entry) => stableConfigStringify(entry)).join(",")}]`; - } - const record = value as Record; - return `{${Object.keys(record) - .toSorted() - .map((key) => `${JSON.stringify(key)}:${stableConfigStringify(record[key])}`) - .join(",")}}`; -} - -function configSnapshotsMatch(left: OpenClawConfig, right: OpenClawConfig): boolean { - if (left === right) { - return true; - } - try { - return stableConfigStringify(left) === stableConfigStringify(right); - } catch { - return false; - } -} - function resolveTtsRuntimeConfig(cfg: OpenClawConfig): OpenClawConfig { - const runtimeConfig = getRuntimeConfigSnapshot(); - if (!runtimeConfig || cfg === runtimeConfig) { - return cfg; - } - const sourceConfig = getRuntimeConfigSourceSnapshot(); - if (!sourceConfig || configSnapshotsMatch(cfg, sourceConfig)) { - return runtimeConfig; - } - return cfg; + return ( + selectApplicableRuntimeConfig({ + inputConfig: cfg, + runtimeConfig: getRuntimeConfigSnapshot(), + runtimeSourceConfig: getRuntimeConfigSourceSnapshot(), + }) ?? cfg + ); } function asProviderConfig(value: unknown): SpeechProviderConfig { diff --git a/src/gateway/server-methods/channels.start.test.ts b/src/gateway/server-methods/channels.start.test.ts index 8240ab2ab88..97371d645c8 100644 --- a/src/gateway/server-methods/channels.start.test.ts +++ b/src/gateway/server-methods/channels.start.test.ts @@ -4,13 +4,14 @@ import type { GatewayRequestHandlerOptions } from "./types.js"; const mocks = vi.hoisted(() => ({ getRuntimeConfig: vi.fn(() => ({})), + readConfigFileSnapshot: vi.fn(), applyPluginAutoEnable: vi.fn(), getChannelPlugin: vi.fn(), })); vi.mock("../../config/config.js", () => ({ getRuntimeConfig: mocks.getRuntimeConfig, - readConfigFileSnapshot: vi.fn(), + readConfigFileSnapshot: mocks.readConfigFileSnapshot, })); vi.mock("../../config/plugin-auto-enable.js", () => ({ @@ -38,6 +39,7 @@ function createOptions( context: { getRuntimeConfig: mocks.getRuntimeConfig, startChannel: vi.fn(), + stopChannel: vi.fn(), getRuntimeSnapshot: vi.fn( (): ChannelRuntimeSnapshot => ({ channels: { @@ -175,3 +177,75 @@ describe("channelsHandlers channels.start", () => { ); }); }); + +describe("channelsHandlers channels.logout", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.readConfigFileSnapshot.mockResolvedValue({ + valid: true, + config: { + channels: { + whatsapp: { + token: { source: "env", provider: "default", id: "WHATSAPP_TOKEN" }, + }, + }, + }, + }); + }); + + it("passes the active runtime config to channel plugins", async () => { + const runtimeConfig = { + channels: { + whatsapp: { + token: "runtime-token", + }, + }, + }; + const stopChannel = vi.fn(); + const markChannelLoggedOut = vi.fn(); + const logoutAccount = vi.fn(async ({ cfg }: { cfg: typeof runtimeConfig }) => { + expect(cfg.channels.whatsapp.token).toBe("runtime-token"); + return { cleared: true, envToken: false, loggedOut: true }; + }); + const respond = vi.fn(); + mocks.getRuntimeConfig.mockReturnValue(runtimeConfig); + mocks.getChannelPlugin.mockReturnValue({ + id: "whatsapp", + gateway: { logoutAccount }, + config: { + defaultAccountId: () => "default-account", + listAccountIds: () => ["default-account"], + resolveAccount: () => ({}), + }, + }); + + await channelsHandlers["channels.logout"]( + createOptions( + { channel: "whatsapp" }, + { + respond, + context: { + getRuntimeConfig: mocks.getRuntimeConfig, + stopChannel, + markChannelLoggedOut, + } as unknown as GatewayRequestHandlerOptions["context"], + }, + ), + ); + + expect(stopChannel).toHaveBeenCalledWith("whatsapp", "default-account"); + expect(markChannelLoggedOut).toHaveBeenCalledWith("whatsapp", true, "default-account"); + expect(logoutAccount).toHaveBeenCalledTimes(1); + expect(respond).toHaveBeenCalledWith( + true, + { + channel: "whatsapp", + accountId: "default-account", + cleared: true, + envToken: false, + loggedOut: true, + }, + undefined, + ); + }); +}); diff --git a/src/gateway/server-methods/channels.ts b/src/gateway/server-methods/channels.ts index be9653b2cdd..c979442d005 100644 --- a/src/gateway/server-methods/channels.ts +++ b/src/gateway/server-methods/channels.ts @@ -437,7 +437,7 @@ export const channelsHandlers: GatewayRequestHandlers = { const payload = await logoutChannelAccount({ channelId, accountId, - cfg: snapshot.config ?? {}, + cfg: context.getRuntimeConfig(), context, plugin, }); diff --git a/src/gateway/server-methods/talk.test.ts b/src/gateway/server-methods/talk.test.ts index 89a34299a2d..bd00b45d90c 100644 --- a/src/gateway/server-methods/talk.test.ts +++ b/src/gateway/server-methods/talk.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; +import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js"; import { talkHandlers } from "./talk.js"; const mocks = vi.hoisted(() => ({ @@ -132,6 +133,111 @@ describe("talk.speak handler", () => { }); }); +describe("talk.config handler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("passes runtime-resolved messages.tts provider secrets to strict provider resolvers", async () => { + const sourceConfig = { + talk: { + provider: "acme", + providers: { + acme: { + voiceId: "voice-from-talk-config", + }, + }, + }, + messages: { + tts: { + provider: "acme", + timeoutMs: 12_345, + providers: { + acme: { + apiKey: { source: "env", provider: "default", id: "ACME_SPEECH_API_KEY" }, + }, + }, + }, + }, + } as OpenClawConfig; + const runtimeConfig = { + ...sourceConfig, + messages: { + tts: { + provider: "acme", + timeoutMs: 54_321, + providers: { + acme: { + apiKey: "env-acme-key", + }, + }, + }, + }, + } as OpenClawConfig; + + mocks.readConfigFileSnapshot.mockResolvedValue({ + path: "/tmp/openclaw.json", + hash: "test-hash", + valid: true, + config: sourceConfig, + }); + mocks.getSpeechProvider.mockReturnValue({ + id: "acme", + label: "Acme Strict Speech", + resolveTalkConfig: ({ + baseTtsConfig, + talkProviderConfig, + timeoutMs, + }: { + baseTtsConfig: Record; + talkProviderConfig: Record; + timeoutMs: number; + }) => { + const providers = (baseTtsConfig.providers ?? {}) as Record; + const providerConfig = (providers.acme ?? {}) as Record; + const apiKey = normalizeResolvedSecretInputString({ + value: providerConfig.apiKey, + path: "messages.tts.providers.acme.apiKey", + }); + expect(apiKey).toBe("env-acme-key"); + expect(timeoutMs).toBe(54_321); + return { + ...talkProviderConfig, + ...(apiKey === undefined ? {} : { apiKey }), + }; + }, + }); + + const respond = vi.fn(); + await talkHandlers["talk.config"]({ + req: { type: "req", id: "1", method: "talk.config" }, + params: {}, + client: { connect: { scopes: ["operator.read"] } } as never, + isWebchatConnect: () => false, + respond: respond as never, + context: { getRuntimeConfig: () => runtimeConfig } as never, + }); + + expect(respond).toHaveBeenCalledWith( + true, + { + config: { + talk: expect.objectContaining({ + provider: "acme", + resolved: { + provider: "acme", + config: expect.objectContaining({ + apiKey: "__OPENCLAW_REDACTED__", + }), + }, + }), + }, + }, + undefined, + ); + }); +}); + describe("talk.realtime.session handler", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/src/gateway/server-methods/talk.ts b/src/gateway/server-methods/talk.ts index 794f088ec61..b036166a0d2 100644 --- a/src/gateway/server-methods/talk.ts +++ b/src/gateway/server-methods/talk.ts @@ -362,6 +362,10 @@ function resolveTalkResponseFromConfig(params: { const runtimeBaseTts = asRecord(params.runtimeConfig.messages?.tts) ?? {}; const sourceProviderConfig = sourceResolved?.config ?? {}; const runtimeProviderConfig = runtimeResolved?.config ?? {}; + const selectedBaseTts = + Object.keys(runtimeBaseTts).length > 0 + ? runtimeBaseTts + : stripUnresolvedSecretApiKeysFromBaseTtsProviders(sourceBaseTts); // Prefer runtime-resolved provider config (already-substituted secrets) and // fall back to source. Strip any apiKey that is still a SecretRef wrapper — // provider plugins (ElevenLabs/OpenAI) call strict secret helpers that throw @@ -371,22 +375,12 @@ function resolveTalkResponseFromConfig(params: { const providerInputConfig = stripUnresolvedSecretApiKey( Object.keys(runtimeProviderConfig).length > 0 ? runtimeProviderConfig : sourceProviderConfig, ); - // The same SecretRef-wrapper hazard exists on `messages.tts.providers.*`: - // strict speech resolvers normalize base TTS secrets before merging talk config. - const baseTtsConfig = stripUnresolvedSecretInputsFromBaseTtsProviders( - Object.keys(sourceBaseTts).length > 0 ? sourceBaseTts : runtimeBaseTts, - ); const resolvedConfig = speechProvider?.resolveTalkConfig?.({ cfg: params.runtimeConfig, - baseTtsConfig, + baseTtsConfig: selectedBaseTts, talkProviderConfig: providerInputConfig, - timeoutMs: - typeof sourceBaseTts.timeoutMs === "number" - ? sourceBaseTts.timeoutMs - : typeof runtimeBaseTts.timeoutMs === "number" - ? runtimeBaseTts.timeoutMs - : 30_000, + timeoutMs: typeof selectedBaseTts.timeoutMs === "number" ? selectedBaseTts.timeoutMs : 30_000, }) ?? providerInputConfig; const responseConfig = sourceProviderConfig.apiKey === undefined @@ -404,31 +398,10 @@ function resolveTalkResponseFromConfig(params: { } function stripUnresolvedSecretApiKey(config: TalkProviderConfig): TalkProviderConfig { - if (config.apiKey === undefined || typeof config.apiKey === "string") { - return config; - } - const { apiKey: _omit, ...rest } = config; - return rest; + return stripUnresolvedSecretApiKeyFromRecord(config) as TalkProviderConfig; } -const BASE_TTS_PROVIDER_SECRET_INPUT_KEYS = ["apiKey", "token"] as const; - -function stripUnresolvedSecretInputsFromProviderConfig( - config: Record, -): Record { - let next: Record | undefined; - for (const key of BASE_TTS_PROVIDER_SECRET_INPUT_KEYS) { - const value = config[key]; - if (value === undefined || typeof value === "string") { - continue; - } - next ??= { ...config }; - delete next[key]; - } - return next ?? config; -} - -function stripUnresolvedSecretInputsFromBaseTtsProviders( +function stripUnresolvedSecretApiKeysFromBaseTtsProviders( base: Record, ): Record { const providers = asRecord(base.providers); @@ -448,7 +421,7 @@ function stripUnresolvedSecretInputsFromBaseTtsProviders( cleaned[providerId] = providerConfig; continue; } - const next = stripUnresolvedSecretInputsFromProviderConfig(cfg); + const next = stripUnresolvedSecretApiKeyFromRecord(cfg); if (next !== cfg) { mutated = true; } @@ -460,6 +433,16 @@ function stripUnresolvedSecretInputsFromBaseTtsProviders( return { ...base, providers: cleaned }; } +function stripUnresolvedSecretApiKeyFromRecord( + config: Record, +): Record { + if (config.apiKey === undefined || typeof config.apiKey === "string") { + return config; + } + const { apiKey: _omit, ...rest } = config; + return rest; +} + export const talkHandlers: GatewayRequestHandlers = { "talk.config": async ({ params, respond, client, context }) => { if (!validateTalkConfigParams(params)) { diff --git a/src/gateway/server.talk-config.test.ts b/src/gateway/server.talk-config.test.ts index 88dc5134e12..5266c68fe56 100644 --- a/src/gateway/server.talk-config.test.ts +++ b/src/gateway/server.talk-config.test.ts @@ -122,7 +122,7 @@ async function fetchTalkConfig( ws: GatewaySocket, params?: { includeSecrets?: boolean } | Record, ) { - return rpcReq(ws, "talk.config", params ?? {}); + return rpcReq(ws, "talk.config", params ?? {}, 60_000); } async function withTalkConfigConnection( @@ -405,99 +405,8 @@ describe("gateway talk.config", () => { }); }); - it("does not throw when SecretRef secrets on messages.tts.providers flow through a strict provider resolver", async () => { - // Regression for the messages.tts.providers. secret-input side of the same - // bug fixed by #72496 for talk.providers..apiKey. Speech provider - // resolvers read the active provider's secret fields out of - // baseTtsConfig.providers[id] to merge with talkProviderConfig, and call - // the same strict normalizeResolvedSecretInputString helper that throws - // on an unresolved SecretRef. Without stripping that wrapper from the - // base TTS providers map before handing it down, talk.config errors out - // even when talk.providers..apiKey is configured cleanly. - const messagesTtsProviderPath = `messages.tts.providers.${GENERIC_TALK_PROVIDER_ID}`; - const { writeConfigFile } = await import("../config/config.js"); - await writeConfigFile({ - talk: { - provider: GENERIC_TALK_PROVIDER_ID, - providers: { - [GENERIC_TALK_PROVIDER_ID]: { - voiceId: "voice-from-talk-config", - }, - }, - }, - messages: { - tts: { - provider: GENERIC_TALK_PROVIDER_ID, - providers: { - [GENERIC_TALK_PROVIDER_ID]: { - apiKey: { source: "env", provider: "default", id: GENERIC_TALK_API_ENV }, - token: { source: "env", provider: "default", id: GENERIC_TALK_API_ENV }, - }, - }, - }, - }, - }); - - await withEnvAsync({ [GENERIC_TALK_API_ENV]: "env-acme-key" }, async () => { - await withSpeechProviders( - [ - { - pluginId: "acme-strict-tts-base-test", - source: "test", - provider: { - id: GENERIC_TALK_PROVIDER_ID, - label: "Acme Strict Speech (messages.tts)", - isConfigured: () => true, - resolveTalkConfig: ({ baseTtsConfig, talkProviderConfig }) => { - // Mirrors strict speech providers: dig into secret inputs on - // the base TTS providers map and feed them through the strict - // resolver that throws on unresolved SecretRefs. - const baseProviders = - (baseTtsConfig as { providers?: Record }).providers ?? {}; - const baseEntry = (baseProviders[GENERIC_TALK_PROVIDER_ID] ?? {}) as { - apiKey?: unknown; - token?: unknown; - }; - const apiKey = normalizeResolvedSecretInputString({ - value: baseEntry.apiKey, - path: `${messagesTtsProviderPath}.apiKey`, - }); - const token = normalizeResolvedSecretInputString({ - value: baseEntry.token, - path: `${messagesTtsProviderPath}.token`, - }); - return { - ...talkProviderConfig, - ...(apiKey === undefined ? {} : { apiKey }), - ...(token === undefined ? {} : { token }), - }; - }, - synthesize: async () => ({ - audioBuffer: Buffer.from([1]), - outputFormat: "mp3", - fileExtension: ".mp3", - voiceCompatible: false, - }), - }, - }, - ], - async () => { - await withTalkConfigConnection(["operator.read"], async (ws) => { - const res = await fetchTalkConfig(ws); - expect(res.ok, JSON.stringify(res.error)).toBe(true); - const talk = res.payload?.config?.talk; - expect(talk?.provider).toBe(GENERIC_TALK_PROVIDER_ID); - expect(talk?.providers?.[GENERIC_TALK_PROVIDER_ID]?.voiceId).toBe( - "voice-from-talk-config", - ); - }); - }, - ); - }); - }); - it("does not pollute Object.prototype when messages.tts.providers contains a __proto__ key", async () => { - // Hardening regression: stripUnresolvedSecretInputsFromBaseTtsProviders + // Hardening regression: stripUnresolvedSecretApiKeysFromBaseTtsProviders // rebuilds the providers map with dynamic keys from operator config. Using // a plain `{}` would let `cleaned['__proto__'] = {...}` mutate // Object.prototype. The helper uses `Object.create(null)` to make that diff --git a/src/plugin-sdk/runtime-config-snapshot.ts b/src/plugin-sdk/runtime-config-snapshot.ts index dae3d56466d..4e3db097eb0 100644 --- a/src/plugin-sdk/runtime-config-snapshot.ts +++ b/src/plugin-sdk/runtime-config-snapshot.ts @@ -1,6 +1,7 @@ export { clearRuntimeConfigSnapshot, getRuntimeConfigSnapshot, + selectApplicableRuntimeConfig, setRuntimeConfigSnapshot, } from "../config/runtime-snapshot.js"; export {