mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-21 16:41:56 +00:00
fix: fail closed talk provider selection
This commit is contained in:
@@ -37,4 +37,68 @@ describe("talk config validation fail-closed behavior", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects talk.provider when it does not match talk.providers during config load", async () => {
|
||||
await withTempHomeConfig(
|
||||
{
|
||||
agents: { list: [{ id: "main" }] },
|
||||
talk: {
|
||||
provider: "acme",
|
||||
providers: {
|
||||
elevenlabs: {
|
||||
voiceId: "voice-123",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
loadConfig();
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(Error);
|
||||
expect((thrown as { code?: string } | undefined)?.code).toBe("INVALID_CONFIG");
|
||||
expect((thrown as Error).message).toMatch(/talk\.provider|talk\.providers|acme/i);
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects multi-provider talk config without talk.provider during config load", async () => {
|
||||
await withTempHomeConfig(
|
||||
{
|
||||
agents: { list: [{ id: "main" }] },
|
||||
talk: {
|
||||
providers: {
|
||||
acme: {
|
||||
voiceId: "voice-acme",
|
||||
},
|
||||
elevenlabs: {
|
||||
voiceId: "voice-eleven",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
loadConfig();
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(Error);
|
||||
expect((thrown as { code?: string } | undefined)?.code).toBe("INVALID_CONFIG");
|
||||
expect((thrown as Error).message).toMatch(/talk\.provider|required/i);
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,10 +158,14 @@ function legacyProviderConfigFromTalk(
|
||||
|
||||
function activeProviderFromTalk(talk: TalkConfig): string | undefined {
|
||||
const provider = normalizeString(talk.provider);
|
||||
const providers = talk.providers;
|
||||
if (provider) {
|
||||
if (providers && !(provider in providers)) {
|
||||
return undefined;
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
const providerIds = talk.providers ? Object.keys(talk.providers) : [];
|
||||
const providerIds = providers ? Object.keys(providers) : [];
|
||||
return providerIds.length === 1 ? providerIds[0] : undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,4 +25,36 @@ describe("OpenClawSchema talk validation", () => {
|
||||
}),
|
||||
).toThrow(/silenceTimeoutMs|number|integer/i);
|
||||
});
|
||||
|
||||
it("rejects talk.provider when it does not match talk.providers", () => {
|
||||
expect(() =>
|
||||
OpenClawSchema.parse({
|
||||
talk: {
|
||||
provider: "acme",
|
||||
providers: {
|
||||
elevenlabs: {
|
||||
voiceId: "voice-123",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrow(/talk\.provider|talk\.providers|missing "acme"/i);
|
||||
});
|
||||
|
||||
it("rejects multi-provider talk config without talk.provider", () => {
|
||||
expect(() =>
|
||||
OpenClawSchema.parse({
|
||||
talk: {
|
||||
providers: {
|
||||
acme: {
|
||||
voiceId: "voice-acme",
|
||||
},
|
||||
elevenlabs: {
|
||||
voiceId: "voice-eleven",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrow(/talk\.provider|required/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,6 +159,50 @@ const PluginEntrySchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const TalkProviderEntrySchema = z
|
||||
.object({
|
||||
voiceId: z.string().optional(),
|
||||
voiceAliases: z.record(z.string(), z.string()).optional(),
|
||||
modelId: z.string().optional(),
|
||||
outputFormat: z.string().optional(),
|
||||
apiKey: SecretInputSchema.optional().register(sensitive),
|
||||
})
|
||||
.catchall(z.unknown());
|
||||
|
||||
const TalkSchema = z
|
||||
.object({
|
||||
provider: z.string().optional(),
|
||||
providers: z.record(z.string(), TalkProviderEntrySchema).optional(),
|
||||
voiceId: z.string().optional(),
|
||||
voiceAliases: z.record(z.string(), z.string()).optional(),
|
||||
modelId: z.string().optional(),
|
||||
outputFormat: z.string().optional(),
|
||||
apiKey: SecretInputSchema.optional().register(sensitive),
|
||||
interruptOnSpeech: z.boolean().optional(),
|
||||
silenceTimeoutMs: z.number().int().positive().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((talk, ctx) => {
|
||||
const provider = talk.provider?.trim().toLowerCase();
|
||||
const providers = talk.providers ? Object.keys(talk.providers) : [];
|
||||
|
||||
if (provider && providers.length > 0 && !(provider in talk.providers!)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["provider"],
|
||||
message: `talk.provider must match a key in talk.providers (missing "${provider}")`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!provider && providers.length > 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["provider"],
|
||||
message: "talk.provider is required when talk.providers defines multiple providers",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const OpenClawSchema = z
|
||||
.object({
|
||||
$schema: z.string().optional(),
|
||||
@@ -572,33 +616,7 @@ export const OpenClawSchema = z
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
talk: z
|
||||
.object({
|
||||
provider: z.string().optional(),
|
||||
providers: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
voiceId: z.string().optional(),
|
||||
voiceAliases: z.record(z.string(), z.string()).optional(),
|
||||
modelId: z.string().optional(),
|
||||
outputFormat: z.string().optional(),
|
||||
apiKey: SecretInputSchema.optional().register(sensitive),
|
||||
})
|
||||
.catchall(z.unknown()),
|
||||
)
|
||||
.optional(),
|
||||
voiceId: z.string().optional(),
|
||||
voiceAliases: z.record(z.string(), z.string()).optional(),
|
||||
modelId: z.string().optional(),
|
||||
outputFormat: z.string().optional(),
|
||||
apiKey: SecretInputSchema.optional().register(sensitive),
|
||||
interruptOnSpeech: z.boolean().optional(),
|
||||
silenceTimeoutMs: z.number().int().positive().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
talk: TalkSchema.optional(),
|
||||
gateway: z
|
||||
.object({
|
||||
port: z.number().int().positive().optional(),
|
||||
|
||||
Reference in New Issue
Block a user