mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
fix: allow device-paired clients to retrieve TTS API keys (#14613)
* refactor: add config.get to READ_METHODS set * refactor(gateway): scope talk secrets via talk.config * fix: resolve rebase conflicts for talk scope refactor --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -119,7 +119,7 @@ class ConnectionManager(
|
||||
fun buildOperatorConnectOptions(): GatewayConnectOptions {
|
||||
return GatewayConnectOptions(
|
||||
role = "operator",
|
||||
scopes = emptyList(),
|
||||
scopes = listOf("operator.read", "operator.write", "operator.talk.secrets"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
|
||||
@@ -814,7 +814,7 @@ class TalkModeManager(
|
||||
val sagVoice = System.getenv("SAG_VOICE_ID")?.trim()
|
||||
val envKey = System.getenv("ELEVENLABS_API_KEY")?.trim()
|
||||
try {
|
||||
val res = session.request("config.get", "{}")
|
||||
val res = session.request("talk.config", """{"includeSecrets":true}""")
|
||||
val root = json.parseToJsonElement(res).asObjectOrNull()
|
||||
val config = root?.get("config").asObjectOrNull()
|
||||
val talk = config?.get("talk").asObjectOrNull()
|
||||
|
||||
@@ -1750,7 +1750,7 @@ private extension NodeAppModel {
|
||||
func makeOperatorConnectOptions(clientId: String, displayName: String?) -> GatewayConnectOptions {
|
||||
GatewayConnectOptions(
|
||||
role: "operator",
|
||||
scopes: ["operator.read", "operator.write", "operator.admin"],
|
||||
scopes: ["operator.read", "operator.write", "operator.talk.secrets"],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
|
||||
@@ -1671,7 +1671,7 @@ extension TalkModeManager {
|
||||
func reloadConfig() async {
|
||||
guard let gateway else { return }
|
||||
do {
|
||||
let res = try await gateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
|
||||
let res = try await gateway.request(method: "talk.config", paramsJSON: "{\"includeSecrets\":true}", timeoutSeconds: 8)
|
||||
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
|
||||
guard let config = json["config"] as? [String: Any] else { return }
|
||||
let talk = config["talk"] as? [String: Any]
|
||||
|
||||
@@ -64,6 +64,7 @@ actor GatewayConnection {
|
||||
case wizardNext = "wizard.next"
|
||||
case wizardCancel = "wizard.cancel"
|
||||
case wizardStatus = "wizard.status"
|
||||
case talkConfig = "talk.config"
|
||||
case talkMode = "talk.mode"
|
||||
case webLoginStart = "web.login.start"
|
||||
case webLoginWait = "web.login.wait"
|
||||
|
||||
@@ -800,8 +800,8 @@ extension TalkModeRuntime {
|
||||
|
||||
do {
|
||||
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .configGet,
|
||||
params: nil,
|
||||
method: .talkConfig,
|
||||
params: ["includeSecrets": AnyCodable(true)],
|
||||
timeoutMs: 8000)
|
||||
let talk = snap.config?["talk"]?.dictionaryValue
|
||||
let ui = snap.config?["ui"]?.dictionaryValue
|
||||
|
||||
@@ -44,6 +44,10 @@ import {
|
||||
AgentWaitParamsSchema,
|
||||
type ChannelsLogoutParams,
|
||||
ChannelsLogoutParamsSchema,
|
||||
type TalkConfigParams,
|
||||
TalkConfigParamsSchema,
|
||||
type TalkConfigResult,
|
||||
TalkConfigResultSchema,
|
||||
type ChannelsStatusParams,
|
||||
ChannelsStatusParamsSchema,
|
||||
type ChannelsStatusResult,
|
||||
@@ -300,6 +304,7 @@ export const validateWizardNextParams = ajv.compile<WizardNextParams>(WizardNext
|
||||
export const validateWizardCancelParams = ajv.compile<WizardCancelParams>(WizardCancelParamsSchema);
|
||||
export const validateWizardStatusParams = ajv.compile<WizardStatusParams>(WizardStatusParamsSchema);
|
||||
export const validateTalkModeParams = ajv.compile<TalkModeParams>(TalkModeParamsSchema);
|
||||
export const validateTalkConfigParams = ajv.compile<TalkConfigParams>(TalkConfigParamsSchema);
|
||||
export const validateChannelsStatusParams = ajv.compile<ChannelsStatusParams>(
|
||||
ChannelsStatusParamsSchema,
|
||||
);
|
||||
@@ -446,6 +451,8 @@ export {
|
||||
WizardNextResultSchema,
|
||||
WizardStartResultSchema,
|
||||
WizardStatusResultSchema,
|
||||
TalkConfigParamsSchema,
|
||||
TalkConfigResultSchema,
|
||||
ChannelsStatusParamsSchema,
|
||||
ChannelsStatusResultSchema,
|
||||
ChannelsLogoutParamsSchema,
|
||||
@@ -532,6 +539,8 @@ export type {
|
||||
WizardNextResult,
|
||||
WizardStartResult,
|
||||
WizardStatusResult,
|
||||
TalkConfigParams,
|
||||
TalkConfigResult,
|
||||
TalkModeParams,
|
||||
ChannelsStatusParams,
|
||||
ChannelsStatusResult,
|
||||
|
||||
@@ -9,6 +9,53 @@ export const TalkModeParamsSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const TalkConfigParamsSchema = Type.Object(
|
||||
{
|
||||
includeSecrets: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const TalkConfigResultSchema = Type.Object(
|
||||
{
|
||||
config: Type.Object(
|
||||
{
|
||||
talk: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
voiceId: Type.Optional(Type.String()),
|
||||
voiceAliases: Type.Optional(Type.Record(Type.String(), Type.String())),
|
||||
modelId: Type.Optional(Type.String()),
|
||||
outputFormat: Type.Optional(Type.String()),
|
||||
apiKey: Type.Optional(Type.String()),
|
||||
interruptOnSpeech: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
session: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
mainKey: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
ui: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
seamColor: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ChannelsStatusParamsSchema = Type.Object(
|
||||
{
|
||||
probe: Type.Optional(Type.Boolean()),
|
||||
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
} from "./agents-models-skills.js";
|
||||
import {
|
||||
ChannelsLogoutParamsSchema,
|
||||
TalkConfigParamsSchema,
|
||||
TalkConfigResultSchema,
|
||||
ChannelsStatusParamsSchema,
|
||||
ChannelsStatusResultSchema,
|
||||
TalkModeParamsSchema,
|
||||
@@ -191,6 +193,8 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
WizardStartResult: WizardStartResultSchema,
|
||||
WizardStatusResult: WizardStatusResultSchema,
|
||||
TalkModeParams: TalkModeParamsSchema,
|
||||
TalkConfigParams: TalkConfigParamsSchema,
|
||||
TalkConfigResult: TalkConfigResultSchema,
|
||||
ChannelsStatusParams: ChannelsStatusParamsSchema,
|
||||
ChannelsStatusResult: ChannelsStatusResultSchema,
|
||||
ChannelsLogoutParams: ChannelsLogoutParamsSchema,
|
||||
|
||||
@@ -35,6 +35,8 @@ import type {
|
||||
} from "./agents-models-skills.js";
|
||||
import type {
|
||||
ChannelsLogoutParamsSchema,
|
||||
TalkConfigParamsSchema,
|
||||
TalkConfigResultSchema,
|
||||
ChannelsStatusParamsSchema,
|
||||
ChannelsStatusResultSchema,
|
||||
TalkModeParamsSchema,
|
||||
@@ -180,6 +182,8 @@ export type WizardNextResult = Static<typeof WizardNextResultSchema>;
|
||||
export type WizardStartResult = Static<typeof WizardStartResultSchema>;
|
||||
export type WizardStatusResult = Static<typeof WizardStatusResultSchema>;
|
||||
export type TalkModeParams = Static<typeof TalkModeParamsSchema>;
|
||||
export type TalkConfigParams = Static<typeof TalkConfigParamsSchema>;
|
||||
export type TalkConfigResult = Static<typeof TalkConfigResultSchema>;
|
||||
export type ChannelsStatusParams = Static<typeof ChannelsStatusParamsSchema>;
|
||||
export type ChannelsStatusResult = Static<typeof ChannelsStatusResultSchema>;
|
||||
export type ChannelsLogoutParams = Static<typeof ChannelsLogoutParamsSchema>;
|
||||
|
||||
@@ -29,6 +29,7 @@ const BASE_METHODS = [
|
||||
"wizard.next",
|
||||
"wizard.cancel",
|
||||
"wizard.status",
|
||||
"talk.config",
|
||||
"talk.mode",
|
||||
"models.list",
|
||||
"agents.list",
|
||||
|
||||
@@ -72,6 +72,8 @@ const READ_METHODS = new Set([
|
||||
"node.list",
|
||||
"node.describe",
|
||||
"chat.history",
|
||||
"config.get",
|
||||
"talk.config",
|
||||
]);
|
||||
const WRITE_METHODS = new Set([
|
||||
"send",
|
||||
|
||||
@@ -1,12 +1,109 @@
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
import { readConfigFileSnapshot } from "../../config/config.js";
|
||||
import { redactConfigObject } from "../../config/redact-snapshot.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
validateTalkConfigParams,
|
||||
validateTalkModeParams,
|
||||
} from "../protocol/index.js";
|
||||
|
||||
const ADMIN_SCOPE = "operator.admin";
|
||||
const TALK_SECRETS_SCOPE = "operator.talk.secrets";
|
||||
|
||||
function canReadTalkSecrets(client: { connect?: { scopes?: string[] } } | null): boolean {
|
||||
const scopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
|
||||
return scopes.includes(ADMIN_SCOPE) || scopes.includes(TALK_SECRETS_SCOPE);
|
||||
}
|
||||
|
||||
function normalizeTalkConfigSection(value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const source = value as Record<string, unknown>;
|
||||
const talk: Record<string, unknown> = {};
|
||||
if (typeof source.voiceId === "string") {
|
||||
talk.voiceId = source.voiceId;
|
||||
}
|
||||
if (
|
||||
source.voiceAliases &&
|
||||
typeof source.voiceAliases === "object" &&
|
||||
!Array.isArray(source.voiceAliases)
|
||||
) {
|
||||
const aliases: Record<string, string> = {};
|
||||
for (const [alias, id] of Object.entries(source.voiceAliases as Record<string, unknown>)) {
|
||||
if (typeof id !== "string") {
|
||||
continue;
|
||||
}
|
||||
aliases[alias] = id;
|
||||
}
|
||||
if (Object.keys(aliases).length > 0) {
|
||||
talk.voiceAliases = aliases;
|
||||
}
|
||||
}
|
||||
if (typeof source.modelId === "string") {
|
||||
talk.modelId = source.modelId;
|
||||
}
|
||||
if (typeof source.outputFormat === "string") {
|
||||
talk.outputFormat = source.outputFormat;
|
||||
}
|
||||
if (typeof source.apiKey === "string") {
|
||||
talk.apiKey = source.apiKey;
|
||||
}
|
||||
if (typeof source.interruptOnSpeech === "boolean") {
|
||||
talk.interruptOnSpeech = source.interruptOnSpeech;
|
||||
}
|
||||
return Object.keys(talk).length > 0 ? talk : undefined;
|
||||
}
|
||||
|
||||
export const talkHandlers: GatewayRequestHandlers = {
|
||||
"talk.config": async ({ params, respond, client }) => {
|
||||
if (!validateTalkConfigParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid talk.config params: ${formatValidationErrors(validateTalkConfigParams.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const includeSecrets = Boolean((params as { includeSecrets?: boolean }).includeSecrets);
|
||||
if (includeSecrets && !canReadTalkSecrets(client)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `missing scope: ${TALK_SECRETS_SCOPE}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
const configPayload: Record<string, unknown> = {};
|
||||
|
||||
const talkSource = includeSecrets
|
||||
? snapshot.config.talk
|
||||
: redactConfigObject(snapshot.config.talk);
|
||||
const talk = normalizeTalkConfigSection(talkSource);
|
||||
if (talk) {
|
||||
configPayload.talk = talk;
|
||||
}
|
||||
|
||||
const sessionMainKey = snapshot.config.session?.mainKey;
|
||||
if (typeof sessionMainKey === "string") {
|
||||
configPayload.session = { mainKey: sessionMainKey };
|
||||
}
|
||||
|
||||
const seamColor = snapshot.config.ui?.seamColor;
|
||||
if (typeof seamColor === "string") {
|
||||
configPayload.ui = { seamColor };
|
||||
}
|
||||
|
||||
respond(true, { config: configPayload }, undefined);
|
||||
},
|
||||
"talk.mode": ({ params, respond, context, client, isWebchatConnect }) => {
|
||||
if (client && isWebchatConnect(client.connect) && !context.hasConnectedMobileNode()) {
|
||||
respond(
|
||||
|
||||
93
src/gateway/server.talk-config.e2e.test.ts
Normal file
93
src/gateway/server.talk-config.e2e.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
connectOk,
|
||||
installGatewayTestHooks,
|
||||
rpcReq,
|
||||
startServerWithClient,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
async function withServer<T>(
|
||||
run: (ws: Awaited<ReturnType<typeof startServerWithClient>>["ws"]) => Promise<T>,
|
||||
) {
|
||||
const { server, ws, prevToken } = await startServerWithClient("secret");
|
||||
try {
|
||||
return await run(ws);
|
||||
} finally {
|
||||
ws.close();
|
||||
await server.close();
|
||||
if (prevToken === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = prevToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("gateway talk.config", () => {
|
||||
it("returns redacted talk config for read scope", async () => {
|
||||
const { writeConfigFile } = await import("../config/config.js");
|
||||
await writeConfigFile({
|
||||
talk: {
|
||||
voiceId: "voice-123",
|
||||
apiKey: "secret-key-abc",
|
||||
},
|
||||
session: {
|
||||
mainKey: "main-test",
|
||||
},
|
||||
ui: {
|
||||
seamColor: "#112233",
|
||||
},
|
||||
});
|
||||
|
||||
await withServer(async (ws) => {
|
||||
await connectOk(ws, { token: "secret", scopes: ["operator.read"] });
|
||||
const res = await rpcReq<{ config?: { talk?: { apiKey?: string; voiceId?: string } } }>(
|
||||
ws,
|
||||
"talk.config",
|
||||
{},
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.payload?.config?.talk?.voiceId).toBe("voice-123");
|
||||
expect(res.payload?.config?.talk?.apiKey).toBe("__OPENCLAW_REDACTED__");
|
||||
});
|
||||
});
|
||||
|
||||
it("requires operator.talk.secrets for includeSecrets", async () => {
|
||||
const { writeConfigFile } = await import("../config/config.js");
|
||||
await writeConfigFile({
|
||||
talk: {
|
||||
apiKey: "secret-key-abc",
|
||||
},
|
||||
});
|
||||
|
||||
await withServer(async (ws) => {
|
||||
await connectOk(ws, { token: "secret", scopes: ["operator.read"] });
|
||||
const res = await rpcReq(ws, "talk.config", { includeSecrets: true });
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message).toContain("missing scope: operator.talk.secrets");
|
||||
});
|
||||
});
|
||||
|
||||
it("returns secrets for operator.talk.secrets scope", async () => {
|
||||
const { writeConfigFile } = await import("../config/config.js");
|
||||
await writeConfigFile({
|
||||
talk: {
|
||||
apiKey: "secret-key-abc",
|
||||
},
|
||||
});
|
||||
|
||||
await withServer(async (ws) => {
|
||||
await connectOk(ws, {
|
||||
token: "secret",
|
||||
scopes: ["operator.read", "operator.write", "operator.talk.secrets"],
|
||||
});
|
||||
const res = await rpcReq<{ config?: { talk?: { apiKey?: string } } }>(ws, "talk.config", {
|
||||
includeSecrets: true,
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.payload?.config?.talk?.apiKey).toBe("secret-key-abc");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user