diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt index 498c3485e22..3b413d2d68b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/ConnectionManager.kt @@ -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(), diff --git a/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt b/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt index d4ca06f50fa..04d18b62260 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/voice/TalkModeManager.kt @@ -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() diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index d41a619aa26..0ca521ccc60 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -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: [:], diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index 0400fd28843..8351a6d5f9a 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -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] diff --git a/apps/macos/Sources/OpenClaw/GatewayConnection.swift b/apps/macos/Sources/OpenClaw/GatewayConnection.swift index f7509236dcc..4cf4d18b151 100644 --- a/apps/macos/Sources/OpenClaw/GatewayConnection.swift +++ b/apps/macos/Sources/OpenClaw/GatewayConnection.swift @@ -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" diff --git a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift index 3da2389bfe6..9ef7b010fa8 100644 --- a/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/TalkModeRuntime.swift @@ -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 diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 0df7c9a76a2..98f1e0e529c 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -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(WizardNext export const validateWizardCancelParams = ajv.compile(WizardCancelParamsSchema); export const validateWizardStatusParams = ajv.compile(WizardStatusParamsSchema); export const validateTalkModeParams = ajv.compile(TalkModeParamsSchema); +export const validateTalkConfigParams = ajv.compile(TalkConfigParamsSchema); export const validateChannelsStatusParams = ajv.compile( 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, diff --git a/src/gateway/protocol/schema/channels.ts b/src/gateway/protocol/schema/channels.ts index cbbaaa1922c..7d864209888 100644 --- a/src/gateway/protocol/schema/channels.ts +++ b/src/gateway/protocol/schema/channels.ts @@ -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()), diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index 6e0e672b508..68670a3d7ed 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -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 = { WizardStartResult: WizardStartResultSchema, WizardStatusResult: WizardStatusResultSchema, TalkModeParams: TalkModeParamsSchema, + TalkConfigParams: TalkConfigParamsSchema, + TalkConfigResult: TalkConfigResultSchema, ChannelsStatusParams: ChannelsStatusParamsSchema, ChannelsStatusResult: ChannelsStatusResultSchema, ChannelsLogoutParams: ChannelsLogoutParamsSchema, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index 1f2b2d6621a..ead66ca789b 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -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; export type WizardStartResult = Static; export type WizardStatusResult = Static; export type TalkModeParams = Static; +export type TalkConfigParams = Static; +export type TalkConfigResult = Static; export type ChannelsStatusParams = Static; export type ChannelsStatusResult = Static; export type ChannelsLogoutParams = Static; diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 1ff570b05b9..b4989aad6a8 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -29,6 +29,7 @@ const BASE_METHODS = [ "wizard.next", "wizard.cancel", "wizard.status", + "talk.config", "talk.mode", "models.list", "agents.list", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 1d8437f73d2..fe79f5d0a88 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -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", diff --git a/src/gateway/server-methods/talk.ts b/src/gateway/server-methods/talk.ts index 78f354b9496..fbe43618ec1 100644 --- a/src/gateway/server-methods/talk.ts +++ b/src/gateway/server-methods/talk.ts @@ -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 | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const source = value as Record; + const talk: Record = {}; + if (typeof source.voiceId === "string") { + talk.voiceId = source.voiceId; + } + if ( + source.voiceAliases && + typeof source.voiceAliases === "object" && + !Array.isArray(source.voiceAliases) + ) { + const aliases: Record = {}; + for (const [alias, id] of Object.entries(source.voiceAliases as Record)) { + 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 = {}; + + 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( diff --git a/src/gateway/server.talk-config.e2e.test.ts b/src/gateway/server.talk-config.e2e.test.ts new file mode 100644 index 00000000000..4cbea64747a --- /dev/null +++ b/src/gateway/server.talk-config.e2e.test.ts @@ -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( + run: (ws: Awaited>["ws"]) => Promise, +) { + 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"); + }); + }); +});