mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-28 06:31:11 +00:00
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
This commit is contained in:
parent
75deb12606
commit
526372ea36
9 changed files with 215 additions and 166 deletions
|
|
@ -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.<id>` 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.<id>.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.<id>.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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
<AccordionGroup>
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -437,7 +437,7 @@ export const channelsHandlers: GatewayRequestHandlers = {
|
|||
const payload = await logoutChannelAccount({
|
||||
channelId,
|
||||
accountId,
|
||||
cfg: snapshot.config ?? {},
|
||||
cfg: context.getRuntimeConfig(),
|
||||
context,
|
||||
plugin,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
talkProviderConfig: Record<string, unknown>;
|
||||
timeoutMs: number;
|
||||
}) => {
|
||||
const providers = (baseTtsConfig.providers ?? {}) as Record<string, unknown>;
|
||||
const providerConfig = (providers.acme ?? {}) as Record<string, unknown>;
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
let next: Record<string, unknown> | 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<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
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<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
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)) {
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ async function fetchTalkConfig(
|
|||
ws: GatewaySocket,
|
||||
params?: { includeSecrets?: boolean } | Record<string, unknown>,
|
||||
) {
|
||||
return rpcReq<TalkConfigPayload>(ws, "talk.config", params ?? {});
|
||||
return rpcReq<TalkConfigPayload>(ws, "talk.config", params ?? {}, 60_000);
|
||||
}
|
||||
|
||||
async function withTalkConfigConnection<T>(
|
||||
|
|
@ -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.<id> secret-input side of the same
|
||||
// bug fixed by #72496 for talk.providers.<id>.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.<id>.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<string, unknown> }).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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export {
|
||||
clearRuntimeConfigSnapshot,
|
||||
getRuntimeConfigSnapshot,
|
||||
selectApplicableRuntimeConfig,
|
||||
setRuntimeConfigSnapshot,
|
||||
} from "../config/runtime-snapshot.js";
|
||||
export {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue