22import { join , parse } from "node:path" ;
33import { afterAll , afterEach , beforeAll , beforeEach , describe , expect , it , vi } from "vitest" ;
44
5+ const oauthRuntimeMocks = vi . hoisted ( ( ) => ( {
6+ loginGeminiCliOAuth : vi . fn ( ) ,
7+ } ) ) ;
8+
59vi . mock ( "openclaw/plugin-sdk/runtime-env" , async ( ) => {
610 const actual = await vi . importActual < typeof import ( "openclaw/plugin-sdk/runtime-env" ) > (
711 "openclaw/plugin-sdk/runtime-env" ,
@@ -12,6 +16,10 @@ vi.mock("openclaw/plugin-sdk/runtime-env", async () => {
1216 } ;
1317} ) ;
1418
19+ vi . mock ( "./oauth.runtime.js" , ( ) => ( {
20+ loginGeminiCliOAuth : oauthRuntimeMocks . loginGeminiCliOAuth ,
21+ } ) ) ;
22+
1523vi . mock ( "openclaw/plugin-sdk/ssrf-runtime" , async ( ) => {
1624 const actual = await vi . importActual < typeof import ( "openclaw/plugin-sdk/ssrf-runtime" ) > (
1725 "openclaw/plugin-sdk/ssrf-runtime" ,
@@ -756,32 +764,40 @@ describe("loginGeminiCliOAuth", () => {
756764
757765 type LoginGeminiCliOAuthFn = ( options : {
758766 isRemote : boolean ;
767+ presentsAuthChallenge ?: boolean ;
759768 openUrl : ( ) => Promise < void > ;
760769 log : ( msg : string ) => void ;
761- note : ( ) => Promise < void > ;
762- prompt : ( ) => Promise < string > ;
770+ note : ( message : string , title ?: string ) => Promise < void > ;
771+ prompt : ( message : string ) => Promise < string > ;
763772 progress : { update : ( ) => void ; stop : ( ) => void } ;
764773 } ) => Promise < { projectId ?: string } > ;
765774
766- async function runRemoteLoginWithCapturedAuthUrl ( loginGeminiCliOAuth : LoginGeminiCliOAuthFn ) {
775+ async function runRemoteLoginWithCapturedAuthUrl (
776+ loginGeminiCliOAuth : LoginGeminiCliOAuthFn ,
777+ options : { presentsAuthChallenge ?: boolean } = { } ,
778+ ) {
767779 let authUrl = "" ;
780+ const note = vi . fn ( async ( ) => { } ) ;
781+ const promptCalls : string [ ] = [ ] ;
768782 const result = await loginGeminiCliOAuth ( {
769783 isRemote : true ,
784+ presentsAuthChallenge : options . presentsAuthChallenge ,
770785 openUrl : async ( ) => { } ,
771786 log : ( msg ) => {
772787 const found = msg . match ( / h t t p s : \/ \/ a c c o u n t s \. g o o g l e \. c o m \/ o \/ o a u t h 2 \/ v 2 \/ a u t h \? [ ^ \s ] + / ) ;
773788 if ( found ?. [ 0 ] ) {
774789 authUrl = found [ 0 ] ;
775790 }
776791 } ,
777- note : async ( ) => { } ,
778- prompt : async ( ) => {
792+ note,
793+ prompt : async ( message ) => {
794+ promptCalls . push ( message ) ;
779795 const state = new URL ( authUrl ) . searchParams . get ( "state" ) ;
780796 return `http://localhost:8085/oauth2callback?code=oauth-code&state=${ state } ` ;
781797 } ,
782798 progress : { update : ( ) => { } , stop : ( ) => { } } ,
783799 } ) ;
784- return { result, authUrl } ;
800+ return { result, authUrl, note , promptCalls } ;
785801 }
786802
787803 async function runProjectDiscoveryExpectingProjectId ( projectId : string ) {
@@ -896,6 +912,62 @@ describe("loginGeminiCliOAuth", () => {
896912 expect ( codeVerifier ) . not . toBe ( authState ) ;
897913 } ) ;
898914
915+ it ( "includes the manual OAuth auth URL in the prompt message" , async ( ) => {
916+ installGeminiOAuthFetchMock ( ( { url } ) => {
917+ if ( url === LOAD_PROD ) {
918+ return responseJson ( {
919+ currentTier : { id : "standard-tier" } ,
920+ cloudaicompanionProject : { id : "prod-project" } ,
921+ } ) ;
922+ }
923+ return undefined ;
924+ } ) ;
925+
926+ const { loginGeminiCliOAuth } = await import ( "./oauth.js" ) ;
927+ const { authUrl, promptCalls } = await runRemoteLoginWithCapturedAuthUrl ( loginGeminiCliOAuth ) ;
928+
929+ expect ( promptCalls ) . toEqual ( [
930+ [
931+ "Open this URL in your LOCAL browser:" ,
932+ "" ,
933+ authUrl ,
934+ "" ,
935+ "After signing in, copy the redirect URL and paste it here:" ,
936+ ] . join ( "\n" ) ,
937+ ] ) ;
938+ } ) ;
939+
940+ it ( "collapses manual OAuth instructions into the auth URL prompt for auth-presenting clients" , async ( ) => {
941+ installGeminiOAuthFetchMock ( ( { url } ) => {
942+ if ( url === LOAD_PROD ) {
943+ return responseJson ( {
944+ currentTier : { id : "standard-tier" } ,
945+ cloudaicompanionProject : { id : "prod-project" } ,
946+ } ) ;
947+ }
948+ return undefined ;
949+ } ) ;
950+
951+ const { loginGeminiCliOAuth } = await import ( "./oauth.js" ) ;
952+ const { authUrl, note, promptCalls } = await runRemoteLoginWithCapturedAuthUrl (
953+ loginGeminiCliOAuth ,
954+ {
955+ presentsAuthChallenge : true ,
956+ } ,
957+ ) ;
958+
959+ expect ( note ) . not . toHaveBeenCalled ( ) ;
960+ expect ( promptCalls ) . toEqual ( [
961+ [
962+ "Open this URL in your LOCAL browser:" ,
963+ "" ,
964+ authUrl ,
965+ "" ,
966+ "After signing in, copy the redirect URL and paste it here:" ,
967+ ] . join ( "\n" ) ,
968+ ] ) ;
969+ } ) ;
970+
899971 it ( "rejects manual callback input when the returned state does not match" , async ( ) => {
900972 const { loginGeminiCliOAuth } = await import ( "./oauth.js" ) ;
901973
@@ -1083,3 +1155,75 @@ describe("loginGeminiCliOAuth", () => {
10831155 expect ( result . expires ) . toBeLessThanOrEqual ( beforeRefresh ) ;
10841156 } ) ;
10851157} ) ;
1158+
1159+ describe ( "Gemini CLI provider OAuth wiring" , ( ) => {
1160+ beforeEach ( ( ) => {
1161+ oauthRuntimeMocks . loginGeminiCliOAuth . mockReset ( ) ;
1162+ } ) ;
1163+
1164+ it ( "forwards manual OAuth URLs into wizard text messages" , async ( ) => {
1165+ const authUrl = "https://accounts.google.com/o/oauth2/v2/auth?state=abc" ;
1166+ oauthRuntimeMocks . loginGeminiCliOAuth . mockImplementation (
1167+ async ( ctx : {
1168+ presentsAuthChallenge ?: boolean ;
1169+ prompt : ( message : string ) => Promise < string > ;
1170+ } ) => {
1171+ expect ( ctx . presentsAuthChallenge ) . toBe ( true ) ;
1172+ await ctx . prompt (
1173+ [
1174+ "Open this URL in your LOCAL browser:" ,
1175+ "" ,
1176+ authUrl ,
1177+ "" ,
1178+ "After signing in, copy the redirect URL and paste it here:" ,
1179+ ] . join ( "\n" ) ,
1180+ ) ;
1181+ return {
1182+ access : "access-token" ,
1183+ refresh : "refresh-token" ,
1184+ expires : Date . now ( ) + 60_000 ,
1185+ 1186+ } ;
1187+ } ,
1188+ ) ;
1189+
1190+ const { buildGoogleGeminiCliProvider } = await import ( "./gemini-cli-provider.js" ) ;
1191+ const provider = buildGoogleGeminiCliProvider ( ) ;
1192+ const method = provider . auth ?. find ( ( candidate ) => candidate . id === "oauth" ) ;
1193+ if ( ! method ) {
1194+ throw new Error ( "expected Gemini CLI OAuth method" ) ;
1195+ }
1196+
1197+ const spin = { update : vi . fn ( ) , stop : vi . fn ( ) } ;
1198+ const text = vi . fn ( async ( ) => "http://localhost:8085/oauth2callback?code=oauth-code" ) ;
1199+
1200+ await method . run ( {
1201+ isRemote : true ,
1202+ openUrl : vi . fn ( async ( ) => { } ) ,
1203+ runtime : {
1204+ log : vi . fn ( ) ,
1205+ error : vi . fn ( ) ,
1206+ exit : vi . fn ( ( code : number ) => {
1207+ throw new Error ( `exit:${ code } ` ) ;
1208+ } ) ,
1209+ } ,
1210+ prompter : {
1211+ presentsAuthChallenge : true ,
1212+ note : vi . fn ( async ( ) => { } ) ,
1213+ confirm : vi . fn ( async ( ) => true ) ,
1214+ progress : vi . fn ( ( ) => spin ) ,
1215+ text,
1216+ } ,
1217+ } as never ) ;
1218+
1219+ expect ( text ) . toHaveBeenCalledWith ( {
1220+ message : [
1221+ "Open this URL in your LOCAL browser:" ,
1222+ "" ,
1223+ authUrl ,
1224+ "" ,
1225+ "After signing in, copy the redirect URL and paste it here:" ,
1226+ ] . join ( "\n" ) ,
1227+ } ) ;
1228+ } ) ;
1229+ } ) ;
0 commit comments