fix(gateway): iOS Talk treats SecretRef-backed API keys as missing (#98210)

* fix(gateway): resolve Talk SecretRefs for scoped native clients

* fix(gateway): constrain Talk secret materialization

* fix(gateway): redact Talk source provider secrets

* fix(gateway): satisfy Talk config lint

* docs(gateway): clarify Talk secret config payload

---------

Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
This commit is contained in:
ooiuuii
2026-06-30 20:17:33 -05:00
committed by GitHub
co-authored by joshavant
parent 3669280b9a
commit 201eb9cd29
4 changed files with 442 additions and 26 deletions
+3
View File
@@ -243,6 +243,9 @@ Common scopes:
`talk.config` with `includeSecrets: true` requires `operator.talk.secrets`
(or `operator.admin`).
When secrets are included, clients should read the active Talk provider
credential from `talk.resolved.config.apiKey`; `talk.providers.<id>.apiKey`
stays source-shaped and may be a SecretRef object or a redacted string.
Plugin-registered gateway RPC methods may request their own operator scope, but
reserved core admin prefixes (`config.*`, `exec.approvals.*`, `wizard.*`,
+320
View File
@@ -516,6 +516,326 @@ describe("talk.config handler", () => {
expectRecordFields(resolved, { provider: "acme" });
expectRecordFields(resolved?.config, { apiKey: "__OPENCLAW_REDACTED__" });
});
it("returns runtime-resolved Talk provider SecretRefs to authorized clients", async () => {
const sourceConfig = createTalkConfig({
source: "env",
provider: "default",
id: "ACME_SPEECH_API_KEY",
});
const runtimeConfig = createTalkConfig("runtime-resolved-talk-key");
mocks.getSpeechProvider.mockReturnValue(undefined);
mocks.readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/openclaw.json",
hash: "test-hash",
valid: true,
config: sourceConfig,
});
const respond = vi.fn();
await talkHandlers["talk.config"]({
req: { type: "req", id: "1", method: "talk.config" },
params: { includeSecrets: true },
client: { connect: { scopes: ["operator.talk.secrets"] } } as never,
isWebchatConnect: () => false,
respond: respond as never,
context: { getRuntimeConfig: () => runtimeConfig } as never,
});
const response = expectRespondOk(respond) as { config?: { talk?: Record<string, unknown> } };
const talkConfig = response.config?.talk;
expectRecordFields(talkConfig, { provider: "acme" });
const providers = talkConfig?.providers as Record<string, unknown> | undefined;
const providerConfig = expectRecordFields(providers?.acme, { voiceId: "stub-default-voice" });
expectRecordFields(providerConfig.apiKey, {
source: "env",
provider: "default",
id: "ACME_SPEECH_API_KEY",
});
const resolved = talkConfig?.resolved as Record<string, unknown> | undefined;
expectRecordFields(resolved, { provider: "acme" });
expectRecordFields(resolved?.config, { apiKey: "runtime-resolved-talk-key" });
});
it("materializes only the active Talk provider apiKey for authorized clients", async () => {
const sourceConfig = {
talk: {
provider: "acme",
providers: {
acme: {
apiKey: { source: "env", provider: "default", id: "ACME_SPEECH_API_KEY" },
voiceId: "active-voice",
},
other: {
apiKey: { source: "env", provider: "default", id: "OTHER_SPEECH_API_KEY" },
voiceId: "inactive-voice",
},
},
realtime: {
provider: "openai",
providers: {
openai: {
apiKey: { source: "env", provider: "default", id: "OPENAI_REALTIME_API_KEY" },
voice: "cedar",
},
},
},
},
} as OpenClawConfig;
const runtimeConfig = {
talk: {
provider: "acme",
providers: {
acme: {
apiKey: "runtime-active-talk-key",
voiceId: "active-voice",
},
other: {
apiKey: "runtime-inactive-talk-key",
voiceId: "inactive-voice",
},
},
realtime: {
provider: "openai",
providers: {
openai: {
apiKey: "runtime-realtime-key",
voice: "cedar",
},
},
},
},
} as OpenClawConfig;
mocks.getSpeechProvider.mockReturnValue(undefined);
mocks.readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/openclaw.json",
hash: "test-hash",
valid: true,
config: sourceConfig,
});
const respond = vi.fn();
await talkHandlers["talk.config"]({
req: { type: "req", id: "1", method: "talk.config" },
params: { includeSecrets: true },
client: { connect: { scopes: ["operator.talk.secrets"] } } as never,
isWebchatConnect: () => false,
respond: respond as never,
context: { getRuntimeConfig: () => runtimeConfig } as never,
});
const response = expectRespondOk(respond) as { config?: { talk?: Record<string, unknown> } };
const talkConfig = response.config?.talk;
const providers = talkConfig?.providers as Record<string, unknown> | undefined;
expectRecordFields((providers?.acme as Record<string, unknown> | undefined)?.apiKey, {
source: "env",
provider: "default",
id: "ACME_SPEECH_API_KEY",
});
expectRecordFields((providers?.other as Record<string, unknown> | undefined)?.apiKey, {
source: "env",
provider: "default",
id: "OTHER_SPEECH_API_KEY",
});
const realtime = talkConfig?.realtime as Record<string, unknown> | undefined;
const realtimeProviders = realtime?.providers as Record<string, unknown> | undefined;
expectRecordFields((realtimeProviders?.openai as Record<string, unknown> | undefined)?.apiKey, {
source: "env",
provider: "default",
id: "OPENAI_REALTIME_API_KEY",
});
const resolved = talkConfig?.resolved as Record<string, unknown> | undefined;
expectRecordFields(resolved, { provider: "acme" });
expectRecordFields(resolved?.config, { apiKey: "runtime-active-talk-key" });
const serialized = JSON.stringify(response);
expect(serialized).toContain("runtime-active-talk-key");
expect(serialized).not.toContain("runtime-inactive-talk-key");
expect(serialized).not.toContain("runtime-realtime-key");
});
it("does not expose resolver-returned secret-like fields beyond apiKey", async () => {
const sourceConfig = createTalkConfig({
source: "env",
provider: "default",
id: "ACME_SPEECH_API_KEY",
});
const runtimeConfig = createTalkConfig("runtime-resolved-talk-key");
mocks.getSpeechProvider.mockReturnValue({
id: "acme",
label: "Acme Speech",
resolveTalkConfig: ({
talkProviderConfig,
}: {
talkProviderConfig: Record<string, unknown>;
}) => ({
...talkProviderConfig,
voiceId: "resolver-voice",
clientSecret: "resolver-client-secret",
authToken: "resolver-auth-token",
}),
});
mocks.readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/openclaw.json",
hash: "test-hash",
valid: true,
config: sourceConfig,
});
const respond = vi.fn();
await talkHandlers["talk.config"]({
req: { type: "req", id: "1", method: "talk.config" },
params: { includeSecrets: true },
client: { connect: { scopes: ["operator.talk.secrets"] } } as never,
isWebchatConnect: () => false,
respond: respond as never,
context: { getRuntimeConfig: () => runtimeConfig } as never,
});
const response = expectRespondOk(respond) as { config?: { talk?: Record<string, unknown> } };
const resolved = response.config?.talk?.resolved as Record<string, unknown> | undefined;
expectRecordFields(resolved?.config, {
apiKey: "runtime-resolved-talk-key",
voiceId: "resolver-voice",
clientSecret: "__OPENCLAW_REDACTED__",
authToken: "__OPENCLAW_REDACTED__",
});
const serialized = JSON.stringify(response);
expect(serialized).not.toContain("resolver-client-secret");
expect(serialized).not.toContain("resolver-auth-token");
});
it("does not expose source provider raw keys or secret-like sibling fields", async () => {
const sourceConfig = {
talk: {
provider: "acme",
providers: {
acme: {
apiKey: "source-active-talk-key",
voiceId: "active-voice",
clientSecret: "source-client-secret",
},
other: {
apiKey: "source-inactive-talk-key",
voiceId: "inactive-voice",
},
},
realtime: {
provider: "openai",
providers: {
openai: {
apiKey: "source-realtime-key",
authToken: "source-realtime-auth-token",
},
},
},
},
} as OpenClawConfig;
const runtimeConfig = {
talk: {
provider: "acme",
providers: {
acme: {
apiKey: "runtime-active-talk-key",
voiceId: "active-voice",
clientSecret: "runtime-client-secret",
},
other: {
apiKey: "runtime-inactive-talk-key",
voiceId: "inactive-voice",
},
},
realtime: {
provider: "openai",
providers: {
openai: {
apiKey: "runtime-realtime-key",
authToken: "runtime-realtime-auth-token",
},
},
},
},
} as OpenClawConfig;
mocks.getSpeechProvider.mockReturnValue(undefined);
mocks.readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/openclaw.json",
hash: "test-hash",
valid: true,
config: sourceConfig,
});
const respond = vi.fn();
await talkHandlers["talk.config"]({
req: { type: "req", id: "1", method: "talk.config" },
params: { includeSecrets: true },
client: { connect: { scopes: ["operator.talk.secrets"] } } as never,
isWebchatConnect: () => false,
respond: respond as never,
context: { getRuntimeConfig: () => runtimeConfig } as never,
});
const response = expectRespondOk(respond) as { config?: { talk?: Record<string, unknown> } };
const resolved = response.config?.talk?.resolved as Record<string, unknown> | undefined;
expectRecordFields(resolved?.config, {
apiKey: "runtime-active-talk-key",
clientSecret: "__OPENCLAW_REDACTED__",
});
const serialized = JSON.stringify(response);
expect(serialized).toContain("runtime-active-talk-key");
expect(serialized).not.toContain("source-active-talk-key");
expect(serialized).not.toContain("source-inactive-talk-key");
expect(serialized).not.toContain("source-realtime-key");
expect(serialized).not.toContain("source-client-secret");
expect(serialized).not.toContain("source-realtime-auth-token");
expect(serialized).not.toContain("runtime-inactive-talk-key");
expect(serialized).not.toContain("runtime-realtime-key");
expect(serialized).not.toContain("runtime-client-secret");
expect(serialized).not.toContain("runtime-realtime-auth-token");
});
it("redacts runtime-resolved Talk provider SecretRefs without Talk secret scope", async () => {
const sourceConfig = createTalkConfig({
source: "env",
provider: "default",
id: "ACME_SPEECH_API_KEY",
});
const runtimeConfig = createTalkConfig("runtime-resolved-talk-key");
mocks.getSpeechProvider.mockReturnValue(undefined);
mocks.readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/openclaw.json",
hash: "test-hash",
valid: true,
config: sourceConfig,
});
const respond = vi.fn();
await talkHandlers["talk.config"]({
req: { type: "req", id: "1", method: "talk.config" },
params: {},
client: { connect: { scopes: ["operator.read"] } } as never,
isWebchatConnect: () => false,
respond: respond as never,
context: { getRuntimeConfig: () => runtimeConfig } as never,
});
const response = expectRespondOk(respond) as { config?: { talk?: Record<string, unknown> } };
const resolved = response.config?.talk?.resolved as Record<string, unknown> | undefined;
expectRecordFields(resolved, { provider: "acme" });
const resolvedConfig = expectRecordFields(resolved?.config, {});
expectRecordFields(resolvedConfig.apiKey, {
source: "__OPENCLAW_REDACTED__",
provider: "__OPENCLAW_REDACTED__",
id: "__OPENCLAW_REDACTED__",
});
const serialized = JSON.stringify(response);
expect(serialized).not.toContain("runtime-resolved-talk-key");
expect(serialized).not.toContain("ACME_SPEECH_API_KEY");
});
});
describe("talk.session unified handlers", () => {
+111 -23
View File
@@ -26,7 +26,11 @@ import {
normalizeTalkSection,
resolveActiveTalkProviderConfig,
} from "../../config/talk.js";
import type { TalkConfigResponse, TalkProviderConfig } from "../../config/types.gateway.js";
import type {
TalkConfigResponse,
TalkProviderConfig,
TalkRealtimeConfig,
} from "../../config/types.gateway.js";
import type { OpenClawConfig, TtsConfig, TtsProviderConfigMap } from "../../config/types.js";
import { listRealtimeTranscriptionProviders } from "../../realtime-transcription/provider-registry.js";
import {
@@ -45,6 +49,7 @@ import {
type TtsDirectiveOverrides,
} from "../../tts/tts.js";
import { ADMIN_SCOPE, TALK_SECRETS_SCOPE } from "../operator-scopes.js";
import { resolveConfiguredSecretInputString } from "../resolve-configured-secret-input-string.js";
import { formatForLog } from "../ws-log.js";
import { talkClientHandlers } from "./talk-client.js";
import { talkSessionHandlers } from "./talk-session.js";
@@ -396,24 +401,23 @@ function inferMimeType(
return undefined;
}
function resolveTalkResponseFromConfig(params: {
async function resolveTalkResponseFromConfig(params: {
includeSecrets: boolean;
sourceConfig: OpenClawConfig;
runtimeConfig: OpenClawConfig;
}): TalkConfigResponse | undefined {
}): Promise<TalkConfigResponse | undefined> {
const normalizedTalk = normalizeTalkSection(params.sourceConfig.talk);
if (!normalizedTalk) {
return undefined;
}
const payload = buildTalkConfigResponse(normalizedTalk);
if (!payload) {
const sourcePayload = buildTalkConfigResponse(normalizedTalk);
if (!sourcePayload) {
return undefined;
}
if (params.includeSecrets) {
return payload;
}
const payload = params.includeSecrets
? projectTalkSourcePayloadForSecrets(sourcePayload)
: sourcePayload;
const sourceResolved = resolveActiveTalkProviderConfig(normalizedTalk);
const runtimeResolved = resolveActiveTalkProviderConfig(params.runtimeConfig.talk);
@@ -436,15 +440,18 @@ function resolveTalkResponseFromConfig(params: {
Object.keys(runtimeBaseTts).length > 0
? runtimeBaseTts
: stripUnresolvedSecretApiKeysFromBaseTtsProviders(sourceBaseTts);
// Prefer runtime-resolved provider config (already-substituted secrets) and
// fall back to source. Strip any apiKey that is still a SecretRef wrapper —
// provider plugins (ElevenLabs/OpenAI) call strict secret helpers that throw
// on unresolved wrappers, and the discovery path doesn't need the resolved
// value: the response's apiKey is restored from source so the UI keeps the
// SecretRef shape, and redaction strips the value when includeSecrets=false.
const providerInputConfig = stripUnresolvedSecretApiKey(
Object.keys(runtimeProviderConfig).length > 0 ? runtimeProviderConfig : sourceProviderConfig,
);
// Prefer runtime-resolved provider config and fall back to source. Provider
// plugins (ElevenLabs/OpenAI) call strict secret helpers that throw on
// unresolved wrappers, so only the already-authorized includeSecrets path may
// materialize SecretRef apiKey values before provider resolution. Read-scope
// calls keep the old strip/redact behavior.
const providerInputConfig = await resolveTalkProviderInputConfig({
includeSecrets: params.includeSecrets,
config: params.runtimeConfig,
providerConfig:
Object.keys(runtimeProviderConfig).length > 0 ? runtimeProviderConfig : sourceProviderConfig,
provider,
});
const resolvedConfig =
speechProvider?.resolveTalkConfig?.({
cfg: params.runtimeConfig,
@@ -452,10 +459,11 @@ function resolveTalkResponseFromConfig(params: {
talkProviderConfig: providerInputConfig,
timeoutMs: typeof selectedBaseTts.timeoutMs === "number" ? selectedBaseTts.timeoutMs : 30_000,
}) ?? providerInputConfig;
const responseConfig =
sourceProviderConfig.apiKey === undefined
? resolvedConfig
: { ...resolvedConfig, apiKey: sourceProviderConfig.apiKey };
const responseConfig = projectTalkResolvedProviderConfig({
includeSecrets: params.includeSecrets,
sourceProviderConfig,
resolvedConfig,
});
return {
...payload,
@@ -467,6 +475,86 @@ function resolveTalkResponseFromConfig(params: {
};
}
function projectTalkResolvedProviderConfig(params: {
includeSecrets: boolean;
sourceProviderConfig: TalkProviderConfig;
resolvedConfig: TalkProviderConfig;
}): TalkProviderConfig {
if (!params.includeSecrets) {
return params.sourceProviderConfig.apiKey === undefined
? params.resolvedConfig
: { ...params.resolvedConfig, apiKey: params.sourceProviderConfig.apiKey };
}
// includeSecrets authorizes the active Talk provider key only. Keep resolver
// defaults in the resolved payload, but do not turn arbitrary provider-owned
// secret-like fields into a new native-client credential surface.
const projected = redactConfigObject(params.resolvedConfig);
const apiKey = normalizeOptionalString(params.resolvedConfig.apiKey);
return apiKey === undefined ? projected : { ...projected, apiKey };
}
function projectTalkSourceProviderConfigForSecrets(config: TalkProviderConfig): TalkProviderConfig {
const projected = redactConfigObject(config);
if (config.apiKey === undefined || typeof config.apiKey === "string") {
return projected;
}
return { ...projected, apiKey: config.apiKey };
}
function projectTalkSourceProviderMapForSecrets(
providers: Record<string, TalkProviderConfig> | undefined,
): Record<string, TalkProviderConfig> | undefined {
if (!providers) {
return undefined;
}
return Object.fromEntries(
Object.entries(providers).map(([providerId, providerConfig]) => [
providerId,
projectTalkSourceProviderConfigForSecrets(providerConfig),
]),
);
}
function projectTalkRealtimeForSecrets(realtime: TalkRealtimeConfig): TalkRealtimeConfig {
const projected = redactConfigObject(realtime);
const providers = projectTalkSourceProviderMapForSecrets(realtime.providers);
return providers ? { ...projected, providers } : projected;
}
function projectTalkSourcePayloadForSecrets(payload: TalkConfigResponse): TalkConfigResponse {
const projected = redactConfigObject(payload);
const providers = projectTalkSourceProviderMapForSecrets(payload.providers);
if (providers) {
projected.providers = providers;
}
if (payload.realtime) {
projected.realtime = projectTalkRealtimeForSecrets(payload.realtime);
}
return projected;
}
async function resolveTalkProviderInputConfig(params: {
includeSecrets: boolean;
config: OpenClawConfig;
providerConfig: TalkProviderConfig;
provider: string;
}): Promise<TalkProviderConfig> {
const strippedConfig = stripUnresolvedSecretApiKey(params.providerConfig);
if (!params.includeSecrets || params.providerConfig.apiKey === undefined) {
return strippedConfig;
}
const resolved = await resolveConfiguredSecretInputString({
config: params.config,
env: process.env,
value: params.providerConfig.apiKey,
path: `talk.providers.${params.provider}.apiKey`,
});
return resolved.value === undefined
? strippedConfig
: { ...params.providerConfig, apiKey: resolved.value };
}
function stripUnresolvedSecretApiKey(config: TalkProviderConfig): TalkProviderConfig {
return stripUnresolvedSecretApiKeyFromRecord(config) as TalkProviderConfig;
}
@@ -564,7 +652,7 @@ export const talkHandlers: GatewayRequestHandlers = {
const runtimeConfig = context.getRuntimeConfig();
const configPayload: Record<string, unknown> = {};
const talk = resolveTalkResponseFromConfig({
const talk = await resolveTalkResponseFromConfig({
includeSecrets,
sourceConfig: snapshot.config,
runtimeConfig,
+8 -3
View File
@@ -302,7 +302,8 @@ describe("gateway talk.config", () => {
expect(res.ok).toBe(true);
expectTalkConfig(res.payload?.config?.talk, {
provider: GENERIC_TALK_PROVIDER_ID,
apiKey: "secret-key-abc",
providerApiKey: "__OPENCLAW_REDACTED__",
resolvedApiKey: "secret-key-abc",
});
});
});
@@ -313,7 +314,10 @@ describe("gateway talk.config", () => {
});
await withEnvAsync({ [GENERIC_TALK_API_ENV]: "env-acme-key" }, async () => {
await expectTalkSecretsConfig({ apiKey: talkApiSecretRef() });
await expectTalkSecretsConfig({
providerApiKey: talkApiSecretRef(),
resolvedApiKey: "env-acme-key",
});
});
});
@@ -402,7 +406,8 @@ describe("gateway talk.config", () => {
await expectTalkSecretsConfig({
voiceId: "voice-secretref",
apiKey: talkApiSecretRef(),
providerApiKey: talkApiSecretRef(),
resolvedApiKey: "env-acme-key",
});
},
);