@@ -17,13 +17,33 @@ type GoogleVertexAuthorizedUserToken = {
1717 refreshToken : string ;
1818} ;
1919
20+ type GoogleVertexAdcToken = {
21+ token : string ;
22+ expiresAtMs : number ;
23+ } ;
24+
2025const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials" ;
2126const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token" ;
27+ const GOOGLE_VERTEX_OAUTH_SCOPE = "https://www.googleapis.com/auth/cloud-platform" ;
28+ // Hold tokens slightly less long than reported expiry (Google's recommendation
29+ // is a 60s buffer) so we don't ship a request that's already revoked when it
30+ // leaves the gateway.
31+ const GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS = 60_000 ;
2232
2333let cachedGoogleVertexAuthorizedUserToken : GoogleVertexAuthorizedUserToken | undefined ;
34+ let cachedGoogleAuthClient :
35+ | {
36+ promise : Promise < {
37+ getAccessToken : ( ) => Promise < string | null | undefined > ;
38+ } > ;
39+ }
40+ | undefined ;
41+ let cachedGoogleVertexAdcToken : GoogleVertexAdcToken | undefined ;
2442
2543export function resetGoogleVertexAuthorizedUserTokenCacheForTest ( ) : void {
2644 cachedGoogleVertexAuthorizedUserToken = undefined ;
45+ cachedGoogleAuthClient = undefined ;
46+ cachedGoogleVertexAdcToken = undefined ;
2747}
2848
2949function normalizeOptionalString ( value : unknown ) : string | undefined {
@@ -85,24 +105,64 @@ async function readGoogleAuthorizedUserCredentials(
85105 } ;
86106}
87107
108+ function readGoogleAdcCredentialsTypeSync ( credentialsPath : string ) : string | undefined {
109+ try {
110+ const parsed = JSON . parse ( readFileSync ( credentialsPath , "utf8" ) ) as unknown ;
111+ if ( ! parsed || typeof parsed !== "object" || Array . isArray ( parsed ) ) {
112+ return undefined ;
113+ }
114+ const type = ( parsed as { type ?: unknown } ) . type ;
115+ return typeof type === "string" ? type : undefined ;
116+ } catch {
117+ return undefined ;
118+ }
119+ }
120+
121+ /**
122+ * Returns true when *any* Application Default Credentials source usable for
123+ * Google Vertex AI is detectable synchronously. We still call the function
124+ * `...AuthorizedUserAdcSync` for backwards compatibility with consumers in
125+ * older OpenClaw revisions; the predicate now also covers:
126+ *
127+ * 1. `authorized_user` credentials file (existing case - `gcloud auth
128+ * application-default login` produces this).
129+ * 2. `external_account` credentials file (Workload Identity Federation).
130+ * 3. `service_account` credentials file (raw GSA key - rarely used in
131+ * OpenClaw, included for completeness).
132+ * 4. GKE Workload Identity (no credentials file; the Google Cloud metadata
133+ * server is the source). Detected heuristically via the presence of
134+ * `KUBERNETES_SERVICE_HOST` (which the kubelet sets in every container).
135+ * The actual metadata-server probe happens at first request time inside
136+ * `google-auth-library`'s `GoogleAuth#getAccessToken()`.
137+ * 5. Cloud Run / GAE / Compute Engine metadata server, detected via
138+ * `K_SERVICE` (Cloud Run), `GAE_SERVICE` (App Engine), or
139+ * `GCE_METADATA_HOST` (Compute Engine override).
140+ *
141+ * The point of the gating in `provider-registration.ts` is to decide whether
142+ * to wire up the Google Vertex transport at all; returning `true` does not
143+ * mean a token is available, it means we have evidence we *could* obtain
144+ * one. The auth call still runs at request time and surfaces clear errors
145+ * when no source is actually usable.
146+ */
88147export function hasGoogleVertexAuthorizedUserAdcSync (
89148 env : NodeJS . ProcessEnv = process . env ,
90149) : boolean {
91150 const credentialsPath = resolveGoogleApplicationCredentialsPath ( env ) ;
92- if ( ! credentialsPath ) {
93- return false ;
151+ if ( credentialsPath ) {
152+ const type = readGoogleAdcCredentialsTypeSync ( credentialsPath ) ;
153+ if ( type === "authorized_user" || type === "external_account" || type === "service_account" ) {
154+ return true ;
155+ }
94156 }
95- try {
96- const parsed = JSON . parse ( readFileSync ( credentialsPath , "utf8" ) ) as unknown ;
97- return (
98- Boolean ( parsed ) &&
99- typeof parsed === "object" &&
100- ! Array . isArray ( parsed ) &&
101- ( parsed as { type ?: unknown } ) . type === "authorized_user"
102- ) ;
103- } catch {
104- return false ;
157+ if (
158+ normalizeOptionalString ( env . KUBERNETES_SERVICE_HOST ) ||
159+ normalizeOptionalString ( env . K_SERVICE ) ||
160+ normalizeOptionalString ( env . GAE_SERVICE ) ||
161+ normalizeOptionalString ( env . GCE_METADATA_HOST )
162+ ) {
163+ return true ;
105164 }
165+ return false ;
106166}
107167
108168async function refreshGoogleVertexAuthorizedUserAccessToken ( params : {
@@ -123,7 +183,7 @@ async function refreshGoogleVertexAuthorizedUserAccessToken(params: {
123183 if (
124184 cached ?. credentialsPath === params . credentialsPath &&
125185 cached . refreshToken === refreshToken &&
126- cached . expiresAtMs - Date . now ( ) > 60_000
186+ cached . expiresAtMs - Date . now ( ) > GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS
127187 ) {
128188 return cached . token ;
129189 }
@@ -166,23 +226,83 @@ async function refreshGoogleVertexAuthorizedUserAccessToken(params: {
166226 return token ;
167227}
168228
229+ async function resolveGoogleVertexAccessTokenViaGoogleAuth ( ) : Promise < string > {
230+ // Lazy-import + cache so we don't pay the google-auth-library load cost on
231+ // gateway startup; only when we actually need a non-authorized_user token.
232+ if ( ! cachedGoogleAuthClient ) {
233+ cachedGoogleAuthClient = {
234+ promise : import ( "google-auth-library" ) . then ( ( { GoogleAuth } ) => {
235+ // GoogleAuth handles every ADC variant we care about for GKE:
236+ // - external_account (Workload Identity Federation: STS exchange)
237+ // - service_account (raw GSA key: JWT-bearer)
238+ // - GKE Workload Identity (metadata server when no credentials file)
239+ // - Compute Engine / Cloud Run / GAE metadata server fallback
240+ // It also caches tokens internally and refreshes before expiry.
241+ return new GoogleAuth ( {
242+ scopes : [ GOOGLE_VERTEX_OAUTH_SCOPE ] ,
243+ } ) ;
244+ } ) ,
245+ } ;
246+ }
247+ const auth = await cachedGoogleAuthClient . promise ;
248+
249+ const cached = cachedGoogleVertexAdcToken ;
250+ if ( cached && cached . expiresAtMs - Date . now ( ) > GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS ) {
251+ return cached . token ;
252+ }
253+
254+ const token = await auth . getAccessToken ( ) ;
255+ const normalized = normalizeOptionalString ( token ) ;
256+ if ( ! normalized ) {
257+ throw new Error (
258+ "Google Vertex ADC fallback (google-auth-library) did not return an access token. " +
259+ "Verify the GKE Workload Identity binding (KSA \u2192 GSA), `GOOGLE_APPLICATION_CREDENTIALS`, " +
260+ "or other ADC source is reachable from this pod." ,
261+ ) ;
262+ }
263+ // google-auth-library doesn't expose token expiry on the simple
264+ // `getAccessToken()` return type, so we cache for a conservative 5 minutes.
265+ // The library itself already refreshes well before its own internal expiry,
266+ // so this cache is mainly to avoid hot-loop calls into the auth client.
267+ cachedGoogleVertexAdcToken = {
268+ token : normalized ,
269+ expiresAtMs : Date . now ( ) + 5 * 60_000 ,
270+ } ;
271+ return normalized ;
272+ }
273+
274+ /**
275+ * Resolve `Authorization: Bearer ...` headers for Google Vertex calls.
276+ *
277+ * We try the hand-rolled `authorized_user` refresh path first (preserves the
278+ * existing fetchImpl test seam and the OpenClaw upstream behaviour); when the
279+ * configured ADC source is anything other than `authorized_user` (the common
280+ * production cases on GKE: Workload Identity, Workload Identity Federation,
281+ * service-account JSON keys), we hand off to `google-auth-library` which
282+ * understands all of those natively.
283+ *
284+ * Note: the function is still named `...AuthorizedUserHeaders` to avoid a
285+ * symbol rename across the existing patch surface; the docstring above is
286+ * the truth, the name is legacy.
287+ */
169288export async function resolveGoogleVertexAuthorizedUserHeaders (
170289 fetchImpl ?: typeof fetch ,
171290) : Promise < Record < string , string > > {
172291 const credentialsPath = resolveGoogleApplicationCredentialsPath ( ) ;
173- if ( ! credentialsPath ) {
174- throw new Error (
175- "Google Vertex ADC credentials not found. Set GOOGLE_APPLICATION_CREDENTIALS or run gcloud auth application-default login." ,
176- ) ;
292+ if ( credentialsPath ) {
293+ const credentials = await readGoogleAuthorizedUserCredentials ( credentialsPath ) ;
294+ if ( credentials ) {
295+ const token = await refreshGoogleVertexAuthorizedUserAccessToken ( {
296+ credentialsPath,
297+ credentials,
298+ fetchImpl,
299+ } ) ;
300+ return { Authorization : `Bearer ${ token } ` } ;
301+ }
177302 }
178- const credentials = await readGoogleAuthorizedUserCredentials ( credentialsPath ) ;
179- if ( ! credentials ) {
180- throw new Error ( "Google Vertex ADC fallback requires an authorized_user credentials file." ) ;
181- }
182- const token = await refreshGoogleVertexAuthorizedUserAccessToken ( {
183- credentialsPath,
184- credentials,
185- fetchImpl,
186- } ) ;
303+ // No file-based authorized_user ADC. Fall back to google-auth-library which
304+ // handles GKE Workload Identity (metadata server), Workload Identity
305+ // Federation (external_account), and service-account keys.
306+ const token = await resolveGoogleVertexAccessTokenViaGoogleAuth ( ) ;
187307 return { Authorization : `Bearer ${ token } ` } ;
188308}
0 commit comments