Skip to content

Commit aa02d36

Browse files
committed
fix google vertex gce metadata adc
1 parent eeef486 commit aa02d36

28 files changed

Lines changed: 895 additions & 99 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
66

77
### Fixes
88

9+
- Google Vertex: fall back to GCE metadata server service-account tokens when local ADC is missing or not `authorized_user`, and teach runtime auth, doctor, and `models status --probe` to recognize VM attached service-account auth without gcloud login or key files.
910
- Release/plugin publishing: retry transient ClawHub CLI dependency install failures, keep preview-passing plugins publishable when one preview cell flakes, and verify every expected ClawHub package version after publish so maintenance releases are faster to recover and less likely to hide partial plugin publishes.
1011
- OpenAI: support `openai/chat-latest` as an explicit direct API-key model override for trying the moving ChatGPT Instant API alias without changing the stable default model.
1112
- Cron CLI: include computed `status` in `cron list --json` and `cron show --json` output so external tooling can read disabled/running/ok/error/skipped/idle state without reimplementing cron status derivation. (#78701) Thanks @aweiker.

docs/auth-credential-semantics.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,11 @@ the target agent signs in separately and creates its own local profile.
7474

7575
## Probe target resolution
7676

77-
- Probe targets can come from auth profiles, environment credentials, or
78-
`models.json`.
77+
- Probe targets can come from auth profiles, environment credentials,
78+
provider-declared live auth evidence, or `models.json`.
79+
- Live auth evidence is only checked by live probe/runtime auth paths. For
80+
example, Google Vertex can use a GCE metadata server access token from an
81+
attached VM service account without a local ADC file.
7982
- If a provider has credentials but OpenClaw cannot resolve a probeable model
8083
candidate for it, `models status --probe` reports `status: no_model` with
8184
`reasonCode: no_model`.

docs/cli/models.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ Probes are real requests (may consume tokens and trigger rate limits).
3939
Use `--agent <id>` to inspect a configured agent’s model/auth state. When omitted,
4040
the command uses `OPENCLAW_AGENT_DIR`/`PI_CODING_AGENT_DIR` if set, otherwise the
4141
configured default agent.
42-
Probe rows can come from auth profiles, env credentials, or `models.json`.
42+
Probe rows can come from auth profiles, env credentials, live provider-owned
43+
auth evidence such as Google Vertex GCE metadata service-account credentials, or
44+
`models.json`.
4345

4446
Notes:
4547

@@ -52,6 +54,10 @@ Notes:
5254
markers, AWS Bedrock env/profile markers, and plugin synthetic-auth metadata;
5355
it does not load provider runtime, read keychain secrets, call provider
5456
APIs, or prove exact per-model execution readiness.
57+
- `models status --probe` may perform provider-declared live auth evidence
58+
checks before building probe targets. For Google Vertex on GCE, a reachable
59+
metadata server token can create a probe target even when no local ADC file or
60+
service-account key is present.
5561
- `models list --all --provider <id>` can include provider-owned static catalog
5662
rows from plugin manifests or bundled provider catalog metadata even when you
5763
have not authenticated with that provider yet. Those rows still show as

docs/plugins/manifest.md

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,13 @@ before runtime loads.
495495
"requiresAllEnv": ["OPENAI_PROJECT"],
496496
"credentialMarker": "openai-local-credentials",
497497
"source": "openai local credentials"
498+
},
499+
{
500+
"type": "gce-metadata-token",
501+
"requiresAnyEnv": ["GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT"],
502+
"requiresAllEnv": ["GOOGLE_CLOUD_LOCATION"],
503+
"credentialMarker": "gcp-vertex-credentials",
504+
"source": "GCE metadata service account"
498505
}
499506
]
500507
}
@@ -547,30 +554,36 @@ registration. These diagnostics are additive and do not reject legacy plugins.
547554

548555
### setup.providers reference
549556

550-
| Field | Required | Type | What it means |
551-
| -------------- | -------- | ---------- | ------------------------------------------------------------------------------------------------ |
552-
| `id` | Yes | `string` | Provider id exposed during setup or onboarding. Keep normalized ids globally unique. |
553-
| `authMethods` | No | `string[]` | Setup/auth method ids this provider supports without loading full runtime. |
554-
| `envVars` | No | `string[]` | Env vars that generic setup/status surfaces can check before plugin runtime loads. |
555-
| `authEvidence` | No | `object[]` | Cheap local auth evidence checks for providers that can authenticate through non-secret markers. |
557+
| Field | Required | Type | What it means |
558+
| -------------- | -------- | ---------- | ------------------------------------------------------------------------------------------ |
559+
| `id` | Yes | `string` | Provider id exposed during setup or onboarding. Keep normalized ids globally unique. |
560+
| `authMethods` | No | `string[]` | Setup/auth method ids this provider supports without loading full runtime. |
561+
| `envVars` | No | `string[]` | Env vars that generic setup/status surfaces can check before plugin runtime loads. |
562+
| `authEvidence` | No | `object[]` | Cheap auth evidence checks for providers that can authenticate through non-secret markers. |
556563

557-
`authEvidence` is for provider-owned local credential markers that can be
558-
verified without loading runtime code. These checks must stay cheap and local:
559-
no network calls, no keychain or secret-manager reads, no shell commands, and no
560-
provider API probes.
564+
`authEvidence` is for provider-owned credential markers that can be verified
565+
without loading runtime code. Non-live checks must stay cheap and local: no
566+
network calls, no keychain or secret-manager reads, no shell commands, and no
567+
provider API probes. Live probe/runtime auth paths may verify explicit live
568+
evidence types such as GCE metadata tokens.
561569

562570
Supported evidence entries:
563571

564572
| Field | Required | Type | What it means |
565573
| ------------------ | -------- | ---------- | -------------------------------------------------------------------------------------------------------------- |
566-
| `type` | Yes | `string` | Currently `local-file-with-env`. |
574+
| `type` | Yes | `string` | `local-file-with-env` or `gce-metadata-token`. |
567575
| `fileEnvVar` | No | `string` | Env var containing an explicit credential file path. |
568576
| `fallbackPaths` | No | `string[]` | Local credential file paths checked when `fileEnvVar` is absent or empty. Supports `${HOME}` and `${APPDATA}`. |
569577
| `requiresAnyEnv` | No | `string[]` | At least one listed env var must be non-empty before the evidence is valid. |
570578
| `requiresAllEnv` | No | `string[]` | Every listed env var must be non-empty before the evidence is valid. |
571579
| `credentialMarker` | Yes | `string` | Non-secret marker returned when the evidence is present. |
572580
| `source` | No | `string` | User-facing source label for auth/status output. |
573581

582+
For `gce-metadata-token`, OpenClaw checks the GCE metadata token endpoint with
583+
the required `Metadata-Flavor: Google` header during live probe/runtime auth
584+
resolution and treats a JSON `access_token` response as evidence. The token is
585+
not persisted.
586+
574587
### setup fields
575588

576589
| Field | Required | Type | What it means |

docs/providers/google.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
summary: "Google Gemini setup (API key + OAuth, image generation, media understanding, TTS, web search)"
2+
summary: "Google Gemini and Vertex setup (API key, OAuth, GCE metadata ADC, image generation, media understanding, TTS, web search)"
33
title: "Google (Gemini)"
44
read_when:
55
- You want to use Google Gemini models with OpenClaw
@@ -13,6 +13,8 @@ Gemini Grounding.
1313
- Provider: `google`
1414
- Auth: `GEMINI_API_KEY` or `GOOGLE_API_KEY`
1515
- API: Google Gemini API
16+
- Vertex provider: `google-vertex`
17+
- Vertex auth: local ADC or GCE metadata service-account ADC
1618
- Runtime option: `agents.defaults.agentRuntime.id: "google-gemini-cli"`
1719
reuses Gemini CLI OAuth while keeping model refs canonical as `google/*`.
1820

@@ -128,6 +130,35 @@ Choose your preferred auth method and follow the setup steps.
128130
</Tab>
129131
</Tabs>
130132

133+
## Vertex AI on GCE
134+
135+
The `google-vertex` provider can use Application Default Credentials from a
136+
local `authorized_user` ADC file, or from the GCE metadata server when OpenClaw
137+
runs on a VM with an attached service account. Metadata auth does not require
138+
`gcloud auth application-default login` or a downloaded service-account key.
139+
140+
Set the Vertex project and location on the gateway host:
141+
142+
```bash
143+
export GOOGLE_CLOUD_PROJECT="my-gcp-project"
144+
export GOOGLE_CLOUD_LOCATION="global"
145+
```
146+
147+
Then configure a Vertex model:
148+
149+
```json5
150+
{
151+
agents: {
152+
defaults: {
153+
model: { primary: "google-vertex/gemini-3.1-pro-preview" },
154+
},
155+
},
156+
}
157+
```
158+
159+
On GCE, `models status --probe` and `openclaw doctor` recognize a reachable
160+
metadata token as provider-owned auth evidence for `google-vertex`.
161+
131162
## Capabilities
132163

133164
| Capability | Supported |

extensions/google/index.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,31 @@ const googleProviderPlugin = {
2020
};
2121

2222
describe("google provider plugin hooks", () => {
23+
it("provides the Vertex transport without requiring file-based ADC discovery first", async () => {
24+
const { providers } = await registerProviderPlugin({
25+
plugin: googleProviderPlugin,
26+
id: "google",
27+
name: "Google Provider",
28+
});
29+
const provider = requireRegisteredProvider(providers, "google");
30+
const streamFn = provider.createStreamFn?.({
31+
model: {
32+
id: "gemini-3.1-pro-preview",
33+
name: "Gemini 3.1 Pro Preview",
34+
api: "google-vertex",
35+
provider: "google-vertex",
36+
baseUrl: "https://{location}-aiplatform.googleapis.com",
37+
reasoning: true,
38+
input: ["text"],
39+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
40+
contextWindow: 128000,
41+
maxTokens: 8192,
42+
} satisfies Model<"google-vertex">,
43+
} as never);
44+
45+
expect(typeof streamFn).toBe("function");
46+
});
47+
2348
it("owns replay policy and reasoning mode for the direct Gemini provider", async () => {
2449
const { providers } = await registerProviderPlugin({
2550
plugin: googleProviderPlugin,

extensions/google/openclaw.plugin.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,13 @@
8989
"requiresAllEnv": ["GOOGLE_CLOUD_LOCATION"],
9090
"credentialMarker": "gcp-vertex-credentials",
9191
"source": "gcloud adc"
92+
},
93+
{
94+
"type": "gce-metadata-token",
95+
"requiresAnyEnv": ["GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT"],
96+
"requiresAllEnv": ["GOOGLE_CLOUD_LOCATION"],
97+
"credentialMarker": "gcp-vertex-credentials",
98+
"source": "GCE metadata service account"
9299
}
93100
]
94101
}

extensions/google/provider-registration.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import {
1313
createGoogleGenerativeAiTransportStreamFn,
1414
createGoogleVertexTransportStreamFn,
1515
} from "./transport-stream.js";
16-
import { hasGoogleVertexAuthorizedUserAdcSync } from "./vertex-adc.js";
1716

1817
export function buildGoogleProvider(): ProviderPlugin {
1918
return {
@@ -57,7 +56,7 @@ export function buildGoogleProvider(): ProviderPlugin {
5756
if (model.api === "google-generative-ai") {
5857
return createGoogleGenerativeAiTransportStreamFn();
5958
}
60-
if (model.api === "google-vertex" && hasGoogleVertexAuthorizedUserAdcSync()) {
59+
if (model.api === "google-vertex") {
6160
return createGoogleVertexTransportStreamFn();
6261
}
6362
return undefined;

extensions/google/transport-stream.test.ts

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ let createGoogleGenerativeAiTransportStreamFn: typeof import("./transport-stream
1919
let createGoogleVertexTransportStreamFn: typeof import("./transport-stream.js").createGoogleVertexTransportStreamFn;
2020
let hasGoogleVertexAuthorizedUserAdcSync: typeof import("./vertex-adc.js").hasGoogleVertexAuthorizedUserAdcSync;
2121
let resetGoogleVertexAuthorizedUserTokenCacheForTest: typeof import("./vertex-adc.js").resetGoogleVertexAuthorizedUserTokenCacheForTest;
22+
let resolveGoogleVertexAuthorizedUserHeaders: typeof import("./vertex-adc.js").resolveGoogleVertexAuthorizedUserHeaders;
2223

2324
const MODEL_PROVIDER_REQUEST_TRANSPORT_SYMBOL = Symbol.for(
2425
"openclaw.modelProviderRequestTransport",
@@ -92,8 +93,11 @@ describe("google transport stream", () => {
9293
createGoogleGenerativeAiTransportStreamFn,
9394
createGoogleVertexTransportStreamFn,
9495
} = await import("./transport-stream.js"));
95-
({ hasGoogleVertexAuthorizedUserAdcSync, resetGoogleVertexAuthorizedUserTokenCacheForTest } =
96-
await import("./vertex-adc.js"));
96+
({
97+
hasGoogleVertexAuthorizedUserAdcSync,
98+
resetGoogleVertexAuthorizedUserTokenCacheForTest,
99+
resolveGoogleVertexAuthorizedUserHeaders,
100+
} = await import("./vertex-adc.js"));
97101
});
98102

99103
beforeEach(() => {
@@ -291,6 +295,73 @@ describe("google transport stream", () => {
291295
);
292296
});
293297

298+
it("uses GCE metadata server credentials for Google Vertex when no ADC file exists", async () => {
299+
const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-metadata-"));
300+
vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", "");
301+
vi.stubEnv("HOME", path.join(tempDir, "home"));
302+
vi.stubEnv("APPDATA", path.join(tempDir, "appdata"));
303+
const metadataFetchMock = vi.fn().mockResolvedValue(
304+
new Response(JSON.stringify({ access_token: " metadata-access-token " }), {
305+
status: 200,
306+
headers: { "content-type": "application/json" },
307+
}),
308+
);
309+
310+
const headers = await resolveGoogleVertexAuthorizedUserHeaders(
311+
metadataFetchMock as unknown as typeof fetch,
312+
);
313+
314+
expect(headers).toEqual({ Authorization: "Bearer metadata-access-token" });
315+
expect(metadataFetchMock).toHaveBeenCalledWith(
316+
"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token",
317+
expect.objectContaining({
318+
headers: { "Metadata-Flavor": "Google" },
319+
}),
320+
);
321+
});
322+
323+
it("throws the missing credentials error when neither ADC nor metadata credentials exist", async () => {
324+
const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-no-adc-"));
325+
vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", "");
326+
vi.stubEnv("HOME", path.join(tempDir, "home"));
327+
vi.stubEnv("APPDATA", path.join(tempDir, "appdata"));
328+
const metadataFetchMock = vi.fn().mockRejectedValue(new Error("metadata unavailable"));
329+
330+
await expect(
331+
resolveGoogleVertexAuthorizedUserHeaders(metadataFetchMock as unknown as typeof fetch),
332+
).rejects.toThrow(
333+
"Google Vertex ADC credentials not found. Set GOOGLE_APPLICATION_CREDENTIALS, run gcloud auth application-default login, or run on GCE with an attached service account.",
334+
);
335+
});
336+
337+
it("uses GCE metadata server credentials for Google Vertex when ADC is not authorized_user", async () => {
338+
const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-sa-adc-"));
339+
const credentialsPath = path.join(tempDir, "application_default_credentials.json");
340+
await writeFile(
341+
credentialsPath,
342+
JSON.stringify({
343+
type: "service_account",
344+
client_email: "vertex-vm@example.iam.gserviceaccount.com",
345+
}),
346+
"utf8",
347+
);
348+
vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", credentialsPath);
349+
const metadataFetchMock = vi.fn().mockResolvedValue(
350+
new Response(JSON.stringify({ access_token: "metadata-service-account-token" }), {
351+
status: 200,
352+
headers: { "content-type": "application/json" },
353+
}),
354+
);
355+
356+
const headers = await resolveGoogleVertexAuthorizedUserHeaders(
357+
metadataFetchMock as unknown as typeof fetch,
358+
);
359+
360+
expect(headers).toEqual({
361+
Authorization: "Bearer metadata-service-account-token",
362+
});
363+
});
364+
294365
it("refreshes authorized_user ADC before Google Vertex requests", async () => {
295366
const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-adc-"));
296367
const credentialsPath = path.join(tempDir, "application_default_credentials.json");

extensions/google/vertex-adc.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ type GoogleVertexAuthorizedUserToken = {
1919

2020
const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials";
2121
const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
22+
const GOOGLE_METADATA_TOKEN_URL =
23+
"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token";
24+
const GOOGLE_METADATA_TOKEN_TIMEOUT_MS = 1_000;
2225

2326
let cachedGoogleVertexAuthorizedUserToken: GoogleVertexAuthorizedUserToken | undefined;
2427

@@ -166,18 +169,53 @@ async function refreshGoogleVertexAuthorizedUserAccessToken(params: {
166169
return token;
167170
}
168171

172+
async function resolveGoogleVertexMetadataHeaders(
173+
fetchImpl?: typeof fetch,
174+
): Promise<Record<string, string> | undefined> {
175+
const controller = new AbortController();
176+
const timeout = setTimeout(() => controller.abort(), GOOGLE_METADATA_TOKEN_TIMEOUT_MS);
177+
try {
178+
const response = await (fetchImpl ?? globalThis.fetch)(GOOGLE_METADATA_TOKEN_URL, {
179+
headers: { "Metadata-Flavor": "Google" },
180+
signal: controller.signal,
181+
});
182+
if (!response.ok) {
183+
return undefined;
184+
}
185+
const payload = (await response.json().catch(() => undefined)) as
186+
| { access_token?: unknown }
187+
| undefined;
188+
const token = normalizeOptionalString(payload?.access_token);
189+
return token ? { Authorization: `Bearer ${token}` } : undefined;
190+
} catch {
191+
return undefined;
192+
} finally {
193+
clearTimeout(timeout);
194+
}
195+
}
196+
169197
export async function resolveGoogleVertexAuthorizedUserHeaders(
170198
fetchImpl?: typeof fetch,
171199
): Promise<Record<string, string>> {
172200
const credentialsPath = resolveGoogleApplicationCredentialsPath();
173201
if (!credentialsPath) {
202+
const metadataHeaders = await resolveGoogleVertexMetadataHeaders(fetchImpl);
203+
if (metadataHeaders) {
204+
return metadataHeaders;
205+
}
174206
throw new Error(
175-
"Google Vertex ADC credentials not found. Set GOOGLE_APPLICATION_CREDENTIALS or run gcloud auth application-default login.",
207+
"Google Vertex ADC credentials not found. Set GOOGLE_APPLICATION_CREDENTIALS, run gcloud auth application-default login, or run on GCE with an attached service account.",
176208
);
177209
}
178210
const credentials = await readGoogleAuthorizedUserCredentials(credentialsPath);
179211
if (!credentials) {
180-
throw new Error("Google Vertex ADC fallback requires an authorized_user credentials file.");
212+
const metadataHeaders = await resolveGoogleVertexMetadataHeaders(fetchImpl);
213+
if (metadataHeaders) {
214+
return metadataHeaders;
215+
}
216+
throw new Error(
217+
"Google Vertex ADC fallback requires an authorized_user credentials file or GCE metadata credentials.",
218+
);
181219
}
182220
const token = await refreshGoogleVertexAuthorizedUserAccessToken({
183221
credentialsPath,

0 commit comments

Comments
 (0)