From 87640f9a6100fe3282cdb4dbf8a1c4599d321b19 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 16:05:44 +0000 Subject: [PATCH] fix: align talk config secret schemas --- src/gateway/protocol/index.test.ts | 40 ++++++++++++++++- src/gateway/protocol/index.ts | 1 + src/gateway/protocol/schema/channels.ts | 6 +-- src/gateway/protocol/schema/primitives.ts | 17 +++++++ src/gateway/server.talk-config.test.ts | 55 ++++++++++++++++++++++- 5 files changed, 114 insertions(+), 5 deletions(-) diff --git a/src/gateway/protocol/index.test.ts b/src/gateway/protocol/index.test.ts index c74e7361db3..3554f326278 100644 --- a/src/gateway/protocol/index.test.ts +++ b/src/gateway/protocol/index.test.ts @@ -1,6 +1,6 @@ import type { ErrorObject } from "ajv"; import { describe, expect, it } from "vitest"; -import { formatValidationErrors } from "./index.js"; +import { formatValidationErrors, validateTalkConfigResult } from "./index.js"; const makeError = (overrides: Partial): ErrorObject => ({ keyword: "type", @@ -62,3 +62,41 @@ describe("formatValidationErrors", () => { ); }); }); + +describe("validateTalkConfigResult", () => { + it("accepts Talk SecretRef payloads", () => { + expect( + validateTalkConfigResult({ + config: { + talk: { + provider: "elevenlabs", + providers: { + elevenlabs: { + apiKey: { + source: "env", + provider: "default", + id: "ELEVENLABS_API_KEY", + }, + }, + }, + resolved: { + provider: "elevenlabs", + config: { + apiKey: { + source: "env", + provider: "default", + id: "ELEVENLABS_API_KEY", + }, + }, + }, + apiKey: { + source: "env", + provider: "default", + id: "ELEVENLABS_API_KEY", + }, + }, + }, + }), + ).toBe(true); + }); +}); diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 507c20025ac..7d3e5a8cb51 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -334,6 +334,7 @@ export const validateWizardCancelParams = ajv.compile(Wizard export const validateWizardStatusParams = ajv.compile(WizardStatusParamsSchema); export const validateTalkModeParams = ajv.compile(TalkModeParamsSchema); export const validateTalkConfigParams = ajv.compile(TalkConfigParamsSchema); +export const validateTalkConfigResult = ajv.compile(TalkConfigResultSchema); export const validateChannelsStatusParams = ajv.compile( ChannelsStatusParamsSchema, ); diff --git a/src/gateway/protocol/schema/channels.ts b/src/gateway/protocol/schema/channels.ts index cfe05819caa..f53c1f5302b 100644 --- a/src/gateway/protocol/schema/channels.ts +++ b/src/gateway/protocol/schema/channels.ts @@ -1,5 +1,5 @@ import { Type } from "@sinclair/typebox"; -import { NonEmptyString } from "./primitives.js"; +import { NonEmptyString, SecretInputSchema } from "./primitives.js"; export const TalkModeParamsSchema = Type.Object( { @@ -22,7 +22,7 @@ const TalkProviderConfigSchema = Type.Object( voiceAliases: Type.Optional(Type.Record(Type.String(), Type.String())), modelId: Type.Optional(Type.String()), outputFormat: Type.Optional(Type.String()), - apiKey: Type.Optional(Type.String()), + apiKey: Type.Optional(SecretInputSchema), }, { additionalProperties: true }, ); @@ -49,7 +49,7 @@ export const TalkConfigResultSchema = Type.Object( voiceAliases: Type.Optional(Type.Record(Type.String(), Type.String())), modelId: Type.Optional(Type.String()), outputFormat: Type.Optional(Type.String()), - apiKey: Type.Optional(Type.String()), + apiKey: Type.Optional(SecretInputSchema), interruptOnSpeech: Type.Optional(Type.Boolean()), silenceTimeoutMs: Type.Optional(Type.Integer({ minimum: 1 })), }, diff --git a/src/gateway/protocol/schema/primitives.ts b/src/gateway/protocol/schema/primitives.ts index 849778149e1..2268d1bde50 100644 --- a/src/gateway/protocol/schema/primitives.ts +++ b/src/gateway/protocol/schema/primitives.ts @@ -20,3 +20,20 @@ export const GatewayClientIdSchema = Type.Union( export const GatewayClientModeSchema = Type.Union( Object.values(GATEWAY_CLIENT_MODES).map((value) => Type.Literal(value)), ); + +export const SecretRefSourceSchema = Type.Union([ + Type.Literal("env"), + Type.Literal("file"), + Type.Literal("exec"), +]); + +export const SecretRefSchema = Type.Object( + { + source: SecretRefSourceSchema, + provider: NonEmptyString, + id: NonEmptyString, + }, + { additionalProperties: false }, +); + +export const SecretInputSchema = Type.Union([Type.String(), SecretRefSchema]); diff --git a/src/gateway/server.talk-config.test.ts b/src/gateway/server.talk-config.test.ts index 5c7fb760ed9..f430edfc185 100644 --- a/src/gateway/server.talk-config.test.ts +++ b/src/gateway/server.talk-config.test.ts @@ -7,6 +7,7 @@ import { signDevicePayload, } from "../infra/device-identity.js"; import { buildDeviceAuthPayload } from "./device-auth.js"; +import { validateTalkConfigResult } from "./protocol/index.js"; import { connectOk, installGatewayTestHooks, @@ -57,7 +58,7 @@ async function connectOperator(ws: GatewaySocket, scopes: string[]) { } async function writeTalkConfig(config: { - apiKey?: string; + apiKey?: string | { source: "env" | "file" | "exec"; provider: string; id: string }; voiceId?: string; silenceTimeoutMs?: number; }) { @@ -140,6 +141,58 @@ describe("gateway talk.config", () => { }); }); + it("returns Talk SecretRef payloads that satisfy the protocol schema", async () => { + await writeTalkConfig({ + apiKey: { + source: "env", + provider: "default", + id: "ELEVENLABS_API_KEY", + }, + }); + + await withServer(async (ws) => { + await connectOperator(ws, ["operator.read", "operator.write", "operator.talk.secrets"]); + const res = await rpcReq<{ + config?: { + talk?: { + apiKey?: { source?: string; provider?: string; id?: string }; + providers?: { + elevenlabs?: { + apiKey?: { source?: string; provider?: string; id?: string }; + }; + }; + resolved?: { + provider?: string; + config?: { + apiKey?: { source?: string; provider?: string; id?: string }; + }; + }; + }; + }; + }>(ws, "talk.config", { + includeSecrets: true, + }); + expect(res.ok).toBe(true); + expect(validateTalkConfigResult(res.payload)).toBe(true); + expect(res.payload?.config?.talk?.apiKey).toEqual({ + source: "env", + provider: "default", + id: "ELEVENLABS_API_KEY", + }); + expect(res.payload?.config?.talk?.providers?.elevenlabs?.apiKey).toEqual({ + source: "env", + provider: "default", + id: "ELEVENLABS_API_KEY", + }); + expect(res.payload?.config?.talk?.resolved?.provider).toBe("elevenlabs"); + expect(res.payload?.config?.talk?.resolved?.config?.apiKey).toEqual({ + source: "env", + provider: "default", + id: "ELEVENLABS_API_KEY", + }); + }); + }); + it("prefers normalized provider payload over conflicting legacy talk keys", async () => { const { writeConfigFile } = await import("../config/config.js"); await writeConfigFile({