11// Command secret gateway tests cover secret resolution for gateway-backed CLI commands.
2- import { beforeEach , describe , expect , it , vi } from "vitest" ;
2+ import fs from "node:fs/promises" ;
3+ import os from "node:os" ;
4+ import path from "node:path" ;
5+ import { afterEach , beforeEach , describe , expect , it , vi } from "vitest" ;
36import type { OpenClawConfig } from "../config/config.js" ;
47import { withEnvAsync } from "../test-utils/env.js" ;
58import {
@@ -18,6 +21,7 @@ const mocks = vi.hoisted(() => ({
1821} ) ) ;
1922
2023const { callGateway } = mocks ;
24+ const tempRoots = new Set < string > ( ) ;
2125
2226vi . mock ( "../gateway/call.js" , ( ) => ( {
2327 callGateway : mocks . callGateway ,
@@ -37,6 +41,13 @@ beforeEach(() => {
3741 commandSecretGatewayTesting . resetDepsForTest ( ) ;
3842} ) ;
3943
44+ afterEach ( async ( ) => {
45+ for ( const root of tempRoots ) {
46+ await fs . rm ( root , { recursive : true , force : true } ) ;
47+ }
48+ tempRoots . clear ( ) ;
49+ } ) ;
50+
4051describe ( "resolveCommandSecretRefsViaGateway" , ( ) => {
4152 function makeTalkProviderApiKeySecretRefConfig ( envKey : string ) : OpenClawConfig {
4253 return buildTalkTestProviderConfig ( { source : "env" , provider : "default" , id : envKey } ) ;
@@ -85,6 +96,56 @@ describe("resolveCommandSecretRefsViaGateway", () => {
8596 ) . toBe ( true ) ;
8697 }
8798
99+ async function createExecProviderConfig ( refId : string ) : Promise < {
100+ config : OpenClawConfig ;
101+ markerPath : string ;
102+ } > {
103+ const root = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "openclaw-command-secret-exec-" ) ) ;
104+ tempRoots . add ( root ) ;
105+ const markerPath = path . join ( root , "executed" ) ;
106+ const resolverScript = [
107+ "const fs = require('node:fs');" ,
108+ "let stdin = '';" ,
109+ "process.stdin.on('data', (chunk) => { stdin += chunk; });" ,
110+ "process.stdin.on('end', () => {" ,
111+ " const request = JSON.parse(stdin);" ,
112+ " fs.writeFileSync(process.env.OPENCLAW_EXEC_MARKER, 'executed');" ,
113+ " const values = Object.fromEntries(request.ids.map((id) => [id, 'exec-local-key']));" ,
114+ " process.stdout.write(JSON.stringify({ protocolVersion: 1, values }));" ,
115+ "});" ,
116+ ] . join ( "\n" ) ;
117+ return {
118+ markerPath,
119+ config : {
120+ ...buildTalkTestProviderConfig ( {
121+ source : "exec" ,
122+ provider : "default" ,
123+ id : refId ,
124+ } ) ,
125+ secrets : {
126+ providers : {
127+ default : {
128+ source : "exec" ,
129+ command : process . execPath ,
130+ args : [ "-e" , resolverScript ] ,
131+ env : { OPENCLAW_EXEC_MARKER : markerPath } ,
132+ allowInsecurePath : true ,
133+ allowSymlinkCommand : true ,
134+ jsonOnly : true ,
135+ } ,
136+ } ,
137+ } ,
138+ } as OpenClawConfig ,
139+ } ;
140+ }
141+
142+ async function markerExists ( markerPath : string ) : Promise < boolean > {
143+ return await fs . access ( markerPath ) . then (
144+ ( ) => true ,
145+ ( ) => false ,
146+ ) ;
147+ }
148+
88149 function readPath ( root : unknown , pathSegments : readonly string [ ] ) : unknown {
89150 let cursor = root ;
90151 for ( const segment of pathSegments ) {
@@ -560,6 +621,133 @@ describe("resolveCommandSecretRefsViaGateway", () => {
560621 } ) ;
561622 } ) ;
562623
624+ it ( "keeps local exec SecretRef fallback enabled by default" , async ( ) => {
625+ const { config, markerPath } = await createExecProviderConfig ( "talk/providers/api-key" ) ;
626+ callGateway . mockRejectedValueOnce ( new Error ( "gateway closed" ) ) ;
627+
628+ const result = await resolveCommandSecretRefsViaGateway ( {
629+ config,
630+ commandName : "memory status" ,
631+ targetIds : new Set ( [ "talk.providers.*.apiKey" ] ) ,
632+ mode : "read_only_status" ,
633+ } ) ;
634+
635+ expect ( await markerExists ( markerPath ) ) . toBe ( true ) ;
636+ expect ( readTalkProviderApiKey ( result . resolvedConfig ) ) . toBe ( "exec-local-key" ) ;
637+ expect ( result . targetStatesByPath [ TALK_TEST_PROVIDER_API_KEY_PATH ] ) . toBe ( "resolved_local" ) ;
638+ expectGatewayUnavailableLocalFallbackDiagnostics ( result ) ;
639+ } ) ;
640+
641+ it ( "skips local exec SecretRef fallback when the caller disallows exec providers" , async ( ) => {
642+ const { config, markerPath } = await createExecProviderConfig ( "talk/providers/api-key" ) ;
643+ callGateway . mockRejectedValueOnce ( new Error ( "gateway closed" ) ) ;
644+
645+ const result = await resolveCommandSecretRefsViaGateway ( {
646+ config,
647+ commandName : "doctor preview" ,
648+ targetIds : new Set ( [ "talk.providers.*.apiKey" ] ) ,
649+ mode : "read_only_status" ,
650+ allowLocalExecSecretRefs : false ,
651+ } ) ;
652+
653+ expect ( await markerExists ( markerPath ) ) . toBe ( false ) ;
654+ expect ( readTalkProviderApiKey ( result . resolvedConfig ) ) . toBeUndefined ( ) ;
655+ expect ( result . targetStatesByPath [ TALK_TEST_PROVIDER_API_KEY_PATH ] ) . toBe ( "unresolved" ) ;
656+ expect ( result . diagnostics ) . toContain (
657+ `doctor preview: ${ TALK_TEST_PROVIDER_API_KEY_PATH } is unavailable in this command path; continuing with degraded read-only config.` ,
658+ ) ;
659+ expect (
660+ result . diagnostics . some ( ( entry ) =>
661+ entry . includes (
662+ "doctor preview: skipped local exec SecretRef resolution for talk.providers.acme-speech.apiKey" ,
663+ ) ,
664+ ) ,
665+ ) . toBe ( true ) ;
666+ expect (
667+ result . diagnostics . some ( ( entry ) =>
668+ entry . includes ( "attempted local command-secret resolution" ) ,
669+ ) ,
670+ ) . toBe ( true ) ;
671+ } ) ;
672+
673+ it ( "can preserve unresolved SecretRefs when local exec fallback is disabled" , async ( ) => {
674+ const { config, markerPath } = await createExecProviderConfig ( "talk/providers/api-key" ) ;
675+ callGateway . mockRejectedValueOnce ( new Error ( "gateway closed" ) ) ;
676+
677+ const result = await resolveCommandSecretRefsViaGateway ( {
678+ config,
679+ commandName : "doctor preview" ,
680+ targetIds : new Set ( [ "talk.providers.*.apiKey" ] ) ,
681+ mode : "read_only_status" ,
682+ allowLocalExecSecretRefs : false ,
683+ scrubUnresolvedSecretRefs : false ,
684+ } ) ;
685+
686+ expect ( await markerExists ( markerPath ) ) . toBe ( false ) ;
687+ expect ( readTalkProviderApiKey ( result . resolvedConfig ) ) . toEqual ( {
688+ source : "exec" ,
689+ provider : "default" ,
690+ id : "talk/providers/api-key" ,
691+ } ) ;
692+ expect ( result . targetStatesByPath [ TALK_TEST_PROVIDER_API_KEY_PATH ] ) . toBe ( "unresolved" ) ;
693+ expect ( result . hadUnresolvedTargets ) . toBe ( true ) ;
694+ } ) ;
695+
696+ it ( "skips gateway resolution when gateway credentials would execute exec SecretRefs" , async ( ) => {
697+ await withEnvAsync (
698+ {
699+ OPENCLAW_GATEWAY_PASSWORD : undefined ,
700+ OPENCLAW_GATEWAY_TOKEN : undefined ,
701+ TALK_API_KEY : "local-fallback-key" ,
702+ } ,
703+ async ( ) => {
704+ const result = await resolveCommandSecretRefsViaGateway ( {
705+ config : {
706+ ...buildTalkTestProviderConfig ( {
707+ source : "env" ,
708+ provider : "env" ,
709+ id : "TALK_API_KEY" ,
710+ } ) ,
711+ gateway : {
712+ auth : {
713+ mode : "token" ,
714+ token : { source : "exec" , provider : "vault" , id : "gateway/token" } ,
715+ } ,
716+ } ,
717+ secrets : {
718+ providers : {
719+ env : { source : "env" } ,
720+ vault : {
721+ source : "exec" ,
722+ command : process . execPath ,
723+ args : [ "-e" , 'process.stdout.write(\'{"values":{"gateway/token":"x"}}\')' ] ,
724+ allowInsecurePath : true ,
725+ allowSymlinkCommand : true ,
726+ jsonOnly : true ,
727+ } ,
728+ } ,
729+ } ,
730+ } as OpenClawConfig ,
731+ commandName : "doctor preview" ,
732+ targetIds : new Set ( [ "talk.providers.*.apiKey" ] ) ,
733+ mode : "read_only_status" ,
734+ allowLocalExecSecretRefs : false ,
735+ } ) ;
736+
737+ expect ( callGateway ) . not . toHaveBeenCalled ( ) ;
738+ expect ( readTalkProviderApiKey ( result . resolvedConfig ) ) . toBe ( "local-fallback-key" ) ;
739+ expect ( result . targetStatesByPath [ TALK_TEST_PROVIDER_API_KEY_PATH ] ) . toBe ( "resolved_local" ) ;
740+ expect (
741+ result . diagnostics . some ( ( entry ) =>
742+ entry . includes (
743+ "doctor preview: skipped gateway secrets.resolve because gateway credentials use exec SecretRefs at gateway.auth.token" ,
744+ ) ,
745+ ) ,
746+ ) . toBe ( true ) ;
747+ } ,
748+ ) ;
749+ } ) ;
750+
563751 it ( "falls back to local resolution for web search SecretRefs when gateway is unavailable" , async ( ) => {
564752 const restoreDeps = setGoogleWebSearchTargetDeps ( ) ;
565753 const envKey = "WEB_SEARCH_GEMINI_API_KEY_LOCAL_FALLBACK" ;
0 commit comments