@@ -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
521841describe ( "talk.session unified handlers" , ( ) => {
0 commit comments