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:
Sk Akram
2026-02-13 21:37:49 +05:30
committed by GitHub
parent c2f7b66d22
commit 4c86821aca
14 changed files with 264 additions and 6 deletions

View File

@@ -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(),

View File

@@ -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()

View File

@@ -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: [:],

View File

@@ -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]

View File

@@ -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"

View File

@@ -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

View File

@@ -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,

View File

@@ -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()),

View File

@@ -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,

View File

@@ -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>;

View File

@@ -29,6 +29,7 @@ const BASE_METHODS = [
"wizard.next",
"wizard.cancel",
"wizard.status",
"talk.config",
"talk.mode",
"models.list",
"agents.list",

View File

@@ -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",

View File

@@ -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(

View 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");
});
});
});