






















@@ -1,6 +1,7 @@
11import os from "node:os";
22import path from "node:path";
33import { afterAll, beforeAll, describe, expect, it } from "vitest";
4+import { normalizeResolvedSecretInputString } from "../config/types.secrets.js";
45import {
56loadOrCreateDeviceIdentity,
67publicKeyRawBase64UrlFromPem,
@@ -323,6 +324,87 @@ describe("gateway talk.config", () => {
323324});
324325});
325326327+it("does not throw when SecretRef apiKey flows through a strict provider resolver", async () => {
328+// Regression for #72496: ElevenLabs/OpenAI speech providers call the strict
329+// normalizeResolvedSecretInputString helper inside resolveTalkConfig. The
330+// discovery path used to hand them the raw source config (with the SecretRef
331+// wrapper still intact), causing talk.config to throw "unresolved SecretRef"
332+// and pushing iOS/macOS Talk overlays onto local AVSpeechSynthesizer.
333+const apiKeyPath = `talk.providers.${GENERIC_TALK_PROVIDER_ID}.apiKey`;
334+await writeTalkConfig({
335+apiKey: { source: "env", provider: "default", id: GENERIC_TALK_API_ENV },
336+voiceId: "voice-secretref",
337+});
338+339+await withEnvAsync({ [GENERIC_TALK_API_ENV]: "env-acme-key" }, async () => {
340+await withSpeechProviders(
341+[
342+{
343+pluginId: "acme-strict-talk-provider-test",
344+source: "test",
345+provider: {
346+id: GENERIC_TALK_PROVIDER_ID,
347+label: "Acme Strict Speech",
348+isConfigured: () => true,
349+resolveTalkConfig: ({ talkProviderConfig }) => {
350+const apiKey = normalizeResolvedSecretInputString({
351+value: talkProviderConfig.apiKey,
352+path: apiKeyPath,
353+});
354+return {
355+ ...talkProviderConfig,
356+ ...(apiKey === undefined ? {} : { apiKey }),
357+};
358+},
359+synthesize: async () => ({
360+audioBuffer: Buffer.from([1]),
361+outputFormat: "mp3",
362+fileExtension: ".mp3",
363+voiceCompatible: false,
364+}),
365+},
366+},
367+],
368+async () => {
369+const secretRef = {
370+source: "env",
371+provider: "default",
372+id: GENERIC_TALK_API_ENV,
373+} satisfies SecretRef;
374+375+await withTalkConfigConnection(["operator.read"], async (ws) => {
376+const res = await fetchTalkConfig(ws);
377+expect(res.ok, JSON.stringify(res.error)).toBe(true);
378+const talk = res.payload?.config?.talk;
379+expect(talk?.provider).toBe(GENERIC_TALK_PROVIDER_ID);
380+expect(talk?.providers?.[GENERIC_TALK_PROVIDER_ID]?.voiceId).toBe("voice-secretref");
381+// SecretRef apiKey is redacted in-place; the wrapper shape stays so
382+// the UI keeps the SecretRef context, but every field becomes the
383+// sentinel so no credential material leaks to read-scope callers.
384+const redactedApiKey = talk?.providers?.[GENERIC_TALK_PROVIDER_ID]?.apiKey;
385+expect(redactedApiKey).toBeTypeOf("object");
386+expect((redactedApiKey as SecretRef).id).toBe("__OPENCLAW_REDACTED__");
387+expect(talk?.resolved?.config?.apiKey).toEqual(redactedApiKey);
388+});
389+390+await withTalkConfigConnection(
391+["operator.read", "operator.write", "operator.talk.secrets"],
392+async (ws) => {
393+const res = await fetchTalkConfig(ws, { includeSecrets: true });
394+expect(res.ok, JSON.stringify(res.error)).toBe(true);
395+expect(validateTalkConfigResult(res.payload)).toBe(true);
396+expectTalkConfig(res.payload?.config?.talk, {
397+provider: GENERIC_TALK_PROVIDER_ID,
398+voiceId: "voice-secretref",
399+apiKey: secretRef,
400+});
401+},
402+);
403+},
404+);
405+});
406+});
407+326408it("returns canonical provider talk payloads", async () => {
327409await writeTalkConfig({
328410provider: GENERIC_TALK_PROVIDER_ID,
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。