Skip to content

Commit 201eb9c

Browse files
ooiuuiijoshavant
andauthored
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 <[email protected]>
1 parent 3669280 commit 201eb9c

4 files changed

Lines changed: 442 additions & 26 deletions

File tree

docs/gateway/protocol.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,9 @@ Common scopes:
243243

244244
`talk.config` with `includeSecrets: true` requires `operator.talk.secrets`
245245
(or `operator.admin`).
246+
When secrets are included, clients should read the active Talk provider
247+
credential from `talk.resolved.config.apiKey`; `talk.providers.<id>.apiKey`
248+
stays source-shaped and may be a SecretRef object or a redacted string.
246249

247250
Plugin-registered gateway RPC methods may request their own operator scope, but
248251
reserved core admin prefixes (`config.*`, `exec.approvals.*`, `wizard.*`,

src/gateway/server-methods/talk.test.ts

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,326 @@ describe("talk.config handler", () => {
516516
expectRecordFields(resolved, { provider: "acme" });
517517
expectRecordFields(resolved?.config, { apiKey: "__OPENCLAW_REDACTED__" });
518518
});
519+
520+
it("returns runtime-resolved Talk provider SecretRefs to authorized clients", async () => {
521+
const sourceConfig = createTalkConfig({
522+
source: "env",
523+
provider: "default",
524+
id: "ACME_SPEECH_API_KEY",
525+
});
526+
const runtimeConfig = createTalkConfig("runtime-resolved-talk-key");
527+
528+
mocks.getSpeechProvider.mockReturnValue(undefined);
529+
mocks.readConfigFileSnapshot.mockResolvedValue({
530+
path: "/tmp/openclaw.json",
531+
hash: "test-hash",
532+
valid: true,
533+
config: sourceConfig,
534+
});
535+
536+
const respond = vi.fn();
537+
await talkHandlers["talk.config"]({
538+
req: { type: "req", id: "1", method: "talk.config" },
539+
params: { includeSecrets: true },
540+
client: { connect: { scopes: ["operator.talk.secrets"] } } as never,
541+
isWebchatConnect: () => false,
542+
respond: respond as never,
543+
context: { getRuntimeConfig: () => runtimeConfig } as never,
544+
});
545+
546+
const response = expectRespondOk(respond) as { config?: { talk?: Record<string, unknown> } };
547+
const talkConfig = response.config?.talk;
548+
expectRecordFields(talkConfig, { provider: "acme" });
549+
const providers = talkConfig?.providers as Record<string, unknown> | undefined;
550+
const providerConfig = expectRecordFields(providers?.acme, { voiceId: "stub-default-voice" });
551+
expectRecordFields(providerConfig.apiKey, {
552+
source: "env",
553+
provider: "default",
554+
id: "ACME_SPEECH_API_KEY",
555+
});
556+
const resolved = talkConfig?.resolved as Record<string, unknown> | undefined;
557+
expectRecordFields(resolved, { provider: "acme" });
558+
expectRecordFields(resolved?.config, { apiKey: "runtime-resolved-talk-key" });
559+
});
560+
561+
it("materializes only the active Talk provider apiKey for authorized clients", async () => {
562+
const sourceConfig = {
563+
talk: {
564+
provider: "acme",
565+
providers: {
566+
acme: {
567+
apiKey: { source: "env", provider: "default", id: "ACME_SPEECH_API_KEY" },
568+
voiceId: "active-voice",
569+
},
570+
other: {
571+
apiKey: { source: "env", provider: "default", id: "OTHER_SPEECH_API_KEY" },
572+
voiceId: "inactive-voice",
573+
},
574+
},
575+
realtime: {
576+
provider: "openai",
577+
providers: {
578+
openai: {
579+
apiKey: { source: "env", provider: "default", id: "OPENAI_REALTIME_API_KEY" },
580+
voice: "cedar",
581+
},
582+
},
583+
},
584+
},
585+
} as OpenClawConfig;
586+
const runtimeConfig = {
587+
talk: {
588+
provider: "acme",
589+
providers: {
590+
acme: {
591+
apiKey: "runtime-active-talk-key",
592+
voiceId: "active-voice",
593+
},
594+
other: {
595+
apiKey: "runtime-inactive-talk-key",
596+
voiceId: "inactive-voice",
597+
},
598+
},
599+
realtime: {
600+
provider: "openai",
601+
providers: {
602+
openai: {
603+
apiKey: "runtime-realtime-key",
604+
voice: "cedar",
605+
},
606+
},
607+
},
608+
},
609+
} as OpenClawConfig;
610+
611+
mocks.getSpeechProvider.mockReturnValue(undefined);
612+
mocks.readConfigFileSnapshot.mockResolvedValue({
613+
path: "/tmp/openclaw.json",
614+
hash: "test-hash",
615+
valid: true,
616+
config: sourceConfig,
617+
});
618+
619+
const respond = vi.fn();
620+
await talkHandlers["talk.config"]({
621+
req: { type: "req", id: "1", method: "talk.config" },
622+
params: { includeSecrets: true },
623+
client: { connect: { scopes: ["operator.talk.secrets"] } } as never,
624+
isWebchatConnect: () => false,
625+
respond: respond as never,
626+
context: { getRuntimeConfig: () => runtimeConfig } as never,
627+
});
628+
629+
const response = expectRespondOk(respond) as { config?: { talk?: Record<string, unknown> } };
630+
const talkConfig = response.config?.talk;
631+
const providers = talkConfig?.providers as Record<string, unknown> | undefined;
632+
expectRecordFields((providers?.acme as Record<string, unknown> | undefined)?.apiKey, {
633+
source: "env",
634+
provider: "default",
635+
id: "ACME_SPEECH_API_KEY",
636+
});
637+
expectRecordFields((providers?.other as Record<string, unknown> | undefined)?.apiKey, {
638+
source: "env",
639+
provider: "default",
640+
id: "OTHER_SPEECH_API_KEY",
641+
});
642+
const realtime = talkConfig?.realtime as Record<string, unknown> | undefined;
643+
const realtimeProviders = realtime?.providers as Record<string, unknown> | undefined;
644+
expectRecordFields((realtimeProviders?.openai as Record<string, unknown> | undefined)?.apiKey, {
645+
source: "env",
646+
provider: "default",
647+
id: "OPENAI_REALTIME_API_KEY",
648+
});
649+
const resolved = talkConfig?.resolved as Record<string, unknown> | undefined;
650+
expectRecordFields(resolved, { provider: "acme" });
651+
expectRecordFields(resolved?.config, { apiKey: "runtime-active-talk-key" });
652+
653+
const serialized = JSON.stringify(response);
654+
expect(serialized).toContain("runtime-active-talk-key");
655+
expect(serialized).not.toContain("runtime-inactive-talk-key");
656+
expect(serialized).not.toContain("runtime-realtime-key");
657+
});
658+
659+
it("does not expose resolver-returned secret-like fields beyond apiKey", async () => {
660+
const sourceConfig = createTalkConfig({
661+
source: "env",
662+
provider: "default",
663+
id: "ACME_SPEECH_API_KEY",
664+
});
665+
const runtimeConfig = createTalkConfig("runtime-resolved-talk-key");
666+
667+
mocks.getSpeechProvider.mockReturnValue({
668+
id: "acme",
669+
label: "Acme Speech",
670+
resolveTalkConfig: ({
671+
talkProviderConfig,
672+
}: {
673+
talkProviderConfig: Record<string, unknown>;
674+
}) => ({
675+
...talkProviderConfig,
676+
voiceId: "resolver-voice",
677+
clientSecret: "resolver-client-secret",
678+
authToken: "resolver-auth-token",
679+
}),
680+
});
681+
mocks.readConfigFileSnapshot.mockResolvedValue({
682+
path: "/tmp/openclaw.json",
683+
hash: "test-hash",
684+
valid: true,
685+
config: sourceConfig,
686+
});
687+
688+
const respond = vi.fn();
689+
await talkHandlers["talk.config"]({
690+
req: { type: "req", id: "1", method: "talk.config" },
691+
params: { includeSecrets: true },
692+
client: { connect: { scopes: ["operator.talk.secrets"] } } as never,
693+
isWebchatConnect: () => false,
694+
respond: respond as never,
695+
context: { getRuntimeConfig: () => runtimeConfig } as never,
696+
});
697+
698+
const response = expectRespondOk(respond) as { config?: { talk?: Record<string, unknown> } };
699+
const resolved = response.config?.talk?.resolved as Record<string, unknown> | undefined;
700+
expectRecordFields(resolved?.config, {
701+
apiKey: "runtime-resolved-talk-key",
702+
voiceId: "resolver-voice",
703+
clientSecret: "__OPENCLAW_REDACTED__",
704+
authToken: "__OPENCLAW_REDACTED__",
705+
});
706+
const serialized = JSON.stringify(response);
707+
expect(serialized).not.toContain("resolver-client-secret");
708+
expect(serialized).not.toContain("resolver-auth-token");
709+
});
710+
711+
it("does not expose source provider raw keys or secret-like sibling fields", async () => {
712+
const sourceConfig = {
713+
talk: {
714+
provider: "acme",
715+
providers: {
716+
acme: {
717+
apiKey: "source-active-talk-key",
718+
voiceId: "active-voice",
719+
clientSecret: "source-client-secret",
720+
},
721+
other: {
722+
apiKey: "source-inactive-talk-key",
723+
voiceId: "inactive-voice",
724+
},
725+
},
726+
realtime: {
727+
provider: "openai",
728+
providers: {
729+
openai: {
730+
apiKey: "source-realtime-key",
731+
authToken: "source-realtime-auth-token",
732+
},
733+
},
734+
},
735+
},
736+
} as OpenClawConfig;
737+
const runtimeConfig = {
738+
talk: {
739+
provider: "acme",
740+
providers: {
741+
acme: {
742+
apiKey: "runtime-active-talk-key",
743+
voiceId: "active-voice",
744+
clientSecret: "runtime-client-secret",
745+
},
746+
other: {
747+
apiKey: "runtime-inactive-talk-key",
748+
voiceId: "inactive-voice",
749+
},
750+
},
751+
realtime: {
752+
provider: "openai",
753+
providers: {
754+
openai: {
755+
apiKey: "runtime-realtime-key",
756+
authToken: "runtime-realtime-auth-token",
757+
},
758+
},
759+
},
760+
},
761+
} as OpenClawConfig;
762+
763+
mocks.getSpeechProvider.mockReturnValue(undefined);
764+
mocks.readConfigFileSnapshot.mockResolvedValue({
765+
path: "/tmp/openclaw.json",
766+
hash: "test-hash",
767+
valid: true,
768+
config: sourceConfig,
769+
});
770+
771+
const respond = vi.fn();
772+
await talkHandlers["talk.config"]({
773+
req: { type: "req", id: "1", method: "talk.config" },
774+
params: { includeSecrets: true },
775+
client: { connect: { scopes: ["operator.talk.secrets"] } } as never,
776+
isWebchatConnect: () => false,
777+
respond: respond as never,
778+
context: { getRuntimeConfig: () => runtimeConfig } as never,
779+
});
780+
781+
const response = expectRespondOk(respond) as { config?: { talk?: Record<string, unknown> } };
782+
const resolved = response.config?.talk?.resolved as Record<string, unknown> | undefined;
783+
expectRecordFields(resolved?.config, {
784+
apiKey: "runtime-active-talk-key",
785+
clientSecret: "__OPENCLAW_REDACTED__",
786+
});
787+
const serialized = JSON.stringify(response);
788+
expect(serialized).toContain("runtime-active-talk-key");
789+
expect(serialized).not.toContain("source-active-talk-key");
790+
expect(serialized).not.toContain("source-inactive-talk-key");
791+
expect(serialized).not.toContain("source-realtime-key");
792+
expect(serialized).not.toContain("source-client-secret");
793+
expect(serialized).not.toContain("source-realtime-auth-token");
794+
expect(serialized).not.toContain("runtime-inactive-talk-key");
795+
expect(serialized).not.toContain("runtime-realtime-key");
796+
expect(serialized).not.toContain("runtime-client-secret");
797+
expect(serialized).not.toContain("runtime-realtime-auth-token");
798+
});
799+
800+
it("redacts runtime-resolved Talk provider SecretRefs without Talk secret scope", async () => {
801+
const sourceConfig = createTalkConfig({
802+
source: "env",
803+
provider: "default",
804+
id: "ACME_SPEECH_API_KEY",
805+
});
806+
const runtimeConfig = createTalkConfig("runtime-resolved-talk-key");
807+
808+
mocks.getSpeechProvider.mockReturnValue(undefined);
809+
mocks.readConfigFileSnapshot.mockResolvedValue({
810+
path: "/tmp/openclaw.json",
811+
hash: "test-hash",
812+
valid: true,
813+
config: sourceConfig,
814+
});
815+
816+
const respond = vi.fn();
817+
await talkHandlers["talk.config"]({
818+
req: { type: "req", id: "1", method: "talk.config" },
819+
params: {},
820+
client: { connect: { scopes: ["operator.read"] } } as never,
821+
isWebchatConnect: () => false,
822+
respond: respond as never,
823+
context: { getRuntimeConfig: () => runtimeConfig } as never,
824+
});
825+
826+
const response = expectRespondOk(respond) as { config?: { talk?: Record<string, unknown> } };
827+
const resolved = response.config?.talk?.resolved as Record<string, unknown> | undefined;
828+
expectRecordFields(resolved, { provider: "acme" });
829+
const resolvedConfig = expectRecordFields(resolved?.config, {});
830+
expectRecordFields(resolvedConfig.apiKey, {
831+
source: "__OPENCLAW_REDACTED__",
832+
provider: "__OPENCLAW_REDACTED__",
833+
id: "__OPENCLAW_REDACTED__",
834+
});
835+
const serialized = JSON.stringify(response);
836+
expect(serialized).not.toContain("runtime-resolved-talk-key");
837+
expect(serialized).not.toContain("ACME_SPEECH_API_KEY");
838+
});
519839
});
520840

521841
describe("talk.session unified handlers", () => {

0 commit comments

Comments
 (0)