@bitkyc08/opencodex 2.6.7 → 2.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Application Default Credentials (ADC) resolution for Vertex AI.
3
+ *
4
+ * Direct WebCrypto + REST implementation (no `google-auth-library`). Sources, in priority order:
5
+ * 1. `GOOGLE_APPLICATION_CREDENTIALS` env → file with `type: "service_account"` (RS256 JWT
6
+ * exchange) or `type: "authorized_user"` (refresh-token exchange).
7
+ * 2. `~/.config/gcloud/application_default_credentials.json` (user ADC; authorized_user flow).
8
+ * 3. GCE / Cloud Run metadata server.
9
+ *
10
+ * Tokens are cached per source key and refreshed `GOOGLE_VERTEX_REFRESH_SKEW_MS` (default 60s)
11
+ * before expiry. Concurrent callers waiting on a refresh share one in-flight promise.
12
+ *
13
+ * Security: never logs the access token, private key, or refresh token.
14
+ */
15
+
16
+ import { Buffer } from "node:buffer";
17
+ import * as os from "node:os";
18
+ import * as path from "node:path";
19
+ import { readFileSync, existsSync, statSync } from "node:fs";
20
+
21
+ /** Injectable fetch (tests pass a mock); defaults to the global fetch. */
22
+ export type FetchImpl = typeof fetch;
23
+
24
+ const OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
25
+ const METADATA_TOKEN_URL = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token";
26
+ const CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
27
+ const JWT_BEARER_GRANT = "urn:ietf:params:oauth:grant-type:jwt-bearer";
28
+
29
+ const TOKEN_TIMEOUT_MS = 15_000;
30
+ const TOKEN_ATTEMPTS = 3;
31
+ const TOKEN_RETRY_BASE_MS = 300;
32
+
33
+ interface CachedToken {
34
+ token: string;
35
+ expiresAtMs: number;
36
+ }
37
+
38
+ interface ServiceAccountCredentials {
39
+ type: "service_account";
40
+ client_email: string;
41
+ private_key: string;
42
+ private_key_id?: string;
43
+ }
44
+
45
+ interface AuthorizedUserCredentials {
46
+ type: "authorized_user";
47
+ client_id: string;
48
+ client_secret: string;
49
+ refresh_token: string;
50
+ }
51
+
52
+ type AdcFileCredentials = ServiceAccountCredentials | AuthorizedUserCredentials;
53
+
54
+ interface TokenResponse {
55
+ access_token: string;
56
+ expires_in: number;
57
+ token_type?: string;
58
+ }
59
+
60
+ const tokenCache = new Map<string, CachedToken>();
61
+ const inflight = new Map<string, Promise<string>>();
62
+
63
+ function getRefreshSkewMs(): number {
64
+ const raw = Number(process.env.GOOGLE_VERTEX_REFRESH_SKEW_MS);
65
+ return Number.isFinite(raw) && raw > 0 ? raw : 60_000;
66
+ }
67
+
68
+ function userAdcPath(): string {
69
+ return path.join(os.homedir(), ".config", "gcloud", "application_default_credentials.json");
70
+ }
71
+
72
+ /**
73
+ * A content-freshness fingerprint for a credential file, so an in-place rewrite (e.g.
74
+ * `gcloud auth application-default login`, or a rotated service-account key at the same path)
75
+ * changes the cache key and invalidates the stale token. Falls back to the bare path when stat fails.
76
+ */
77
+ function fileSourceTag(prefix: string, filePath: string): string {
78
+ try {
79
+ const st = statSync(filePath);
80
+ return `${prefix}:${filePath}:${st.size}:${Math.floor(st.mtimeMs)}`;
81
+ } catch {
82
+ return `${prefix}:${filePath}`;
83
+ }
84
+ }
85
+
86
+ function readJsonFile<T>(filePath: string): T | undefined {
87
+ if (!existsSync(filePath)) return undefined;
88
+ return JSON.parse(readFileSync(filePath, "utf8")) as T;
89
+ }
90
+
91
+ function loadAdcCredentials(): { source: string; creds: AdcFileCredentials } | undefined {
92
+ const gacPath = process.env.GOOGLE_APPLICATION_CREDENTIALS;
93
+ if (gacPath) {
94
+ const creds = readJsonFile<AdcFileCredentials>(gacPath);
95
+ if (!creds) throw new Error(`GOOGLE_APPLICATION_CREDENTIALS points to a missing file: ${gacPath}`);
96
+ return { source: fileSourceTag("gac", gacPath), creds };
97
+ }
98
+ const userPath = userAdcPath();
99
+ const creds = readJsonFile<AdcFileCredentials>(userPath);
100
+ if (creds) return { source: fileSourceTag("user", userPath), creds };
101
+ return undefined;
102
+ }
103
+
104
+ /**
105
+ * The cache key for the source the NEXT resolve would use, computed cheaply (no network). Lets the
106
+ * cache return a token only when it still matches the active credential source, so an in-process
107
+ * change to GOOGLE_APPLICATION_CREDENTIALS (or the user ADC file) does not keep serving a stale
108
+ * token from a different source. Falls back to "metadata" when no file/env ADC is present.
109
+ */
110
+ function currentAdcSourceKey(): string {
111
+ const gacPath = process.env.GOOGLE_APPLICATION_CREDENTIALS;
112
+ if (gacPath) return fileSourceTag("gac", gacPath);
113
+ const userPath = userAdcPath();
114
+ if (existsSync(userPath)) return fileSourceTag("user", userPath);
115
+ return "metadata";
116
+ }
117
+
118
+ function base64UrlEncode(bytes: Uint8Array | string): string {
119
+ const buf = typeof bytes === "string" ? Buffer.from(bytes, "utf8") : Buffer.from(bytes);
120
+ return buf.toString("base64url");
121
+ }
122
+
123
+ function pemToPkcs8(pem: string): ArrayBuffer {
124
+ const body = pem
125
+ .replace(/-----BEGIN [^-]+-----/g, "")
126
+ .replace(/-----END [^-]+-----/g, "")
127
+ .replace(/\s+/g, "");
128
+ if (!body) throw new Error("Invalid PEM: empty body");
129
+ const buf = Buffer.from(body, "base64");
130
+ return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer;
131
+ }
132
+
133
+ async function signJwtRs256(claims: Record<string, unknown>, privateKeyPem: string, keyId?: string): Promise<string> {
134
+ const header: Record<string, unknown> = { alg: "RS256", typ: "JWT" };
135
+ if (keyId) header.kid = keyId;
136
+ const payload = `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(JSON.stringify(claims))}`;
137
+ const key = await globalThis.crypto.subtle.importKey(
138
+ "pkcs8",
139
+ pemToPkcs8(privateKeyPem),
140
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
141
+ false,
142
+ ["sign"],
143
+ );
144
+ const signature = new Uint8Array(
145
+ await globalThis.crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, new TextEncoder().encode(payload)),
146
+ );
147
+ return `${payload}.${base64UrlEncode(signature)}`;
148
+ }
149
+
150
+ function tokenRetryDelayMs(attempt: number): number {
151
+ const exp = TOKEN_RETRY_BASE_MS * 2 ** attempt;
152
+ return Math.floor(exp * (0.8 + Math.random() * 0.4));
153
+ }
154
+
155
+ function isRetryableTokenStatus(status: number): boolean {
156
+ return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
157
+ }
158
+
159
+ function tokenTimeoutSignal(parent: AbortSignal | undefined): AbortSignal {
160
+ const timeout = AbortSignal.timeout(TOKEN_TIMEOUT_MS);
161
+ return parent ? AbortSignal.any([parent, timeout]) : timeout;
162
+ }
163
+
164
+ /**
165
+ * Exchange a grant for a Google access token, hardened with a per-attempt timeout + bounded retry
166
+ * on transient failures (network errors and 429/5xx). Non-retryable statuses (e.g. 400/401 from a
167
+ * bad grant) fail fast. The error message carries only the status code, never the response body
168
+ * (which can leak grant/account details) or the token/key.
169
+ */
170
+ async function postForToken(body: URLSearchParams, signal: AbortSignal | undefined, fetchImpl: FetchImpl): Promise<TokenResponse> {
171
+ let lastError: unknown;
172
+ for (let attempt = 0; attempt < TOKEN_ATTEMPTS; attempt++) {
173
+ if (signal?.aborted) throw signal.reason ?? new Error("Google OAuth token exchange aborted");
174
+ let response: Response;
175
+ try {
176
+ response = await fetchImpl(OAUTH_TOKEN_URL, {
177
+ method: "POST",
178
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
179
+ body: body.toString(),
180
+ signal: tokenTimeoutSignal(signal),
181
+ });
182
+ } catch (err) {
183
+ if (signal?.aborted) throw err;
184
+ lastError = err;
185
+ if (attempt === TOKEN_ATTEMPTS - 1) break;
186
+ await new Promise(resolve => setTimeout(resolve, tokenRetryDelayMs(attempt)));
187
+ continue;
188
+ }
189
+ if (response.ok) return (await response.json()) as TokenResponse;
190
+ if (!isRetryableTokenStatus(response.status) || attempt === TOKEN_ATTEMPTS - 1) {
191
+ throw new Error(`Google OAuth token exchange failed (${response.status})`);
192
+ }
193
+ lastError = new Error(`Google OAuth token exchange failed (${response.status})`);
194
+ await response.body?.cancel().catch(() => {});
195
+ await new Promise(resolve => setTimeout(resolve, tokenRetryDelayMs(attempt)));
196
+ }
197
+ throw lastError instanceof Error ? lastError : new Error("Google OAuth token exchange failed");
198
+ }
199
+
200
+ async function exchangeJwtForToken(creds: ServiceAccountCredentials, signal: AbortSignal | undefined, fetchImpl: FetchImpl): Promise<TokenResponse> {
201
+ const now = Math.floor(Date.now() / 1000);
202
+ const assertion = await signJwtRs256(
203
+ { iss: creds.client_email, scope: CLOUD_PLATFORM_SCOPE, aud: OAUTH_TOKEN_URL, exp: now + 3600, iat: now },
204
+ creds.private_key,
205
+ creds.private_key_id,
206
+ );
207
+ return postForToken(new URLSearchParams({ grant_type: JWT_BEARER_GRANT, assertion }), signal, fetchImpl);
208
+ }
209
+
210
+ async function exchangeRefreshToken(creds: AuthorizedUserCredentials, signal: AbortSignal | undefined, fetchImpl: FetchImpl): Promise<TokenResponse> {
211
+ return postForToken(
212
+ new URLSearchParams({
213
+ client_id: creds.client_id,
214
+ client_secret: creds.client_secret,
215
+ refresh_token: creds.refresh_token,
216
+ grant_type: "refresh_token",
217
+ }),
218
+ signal,
219
+ fetchImpl,
220
+ );
221
+ }
222
+
223
+ async function fetchMetadataToken(signal: AbortSignal | undefined, fetchImpl: FetchImpl): Promise<TokenResponse | undefined> {
224
+ const timeout = AbortSignal.timeout(2000);
225
+ const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
226
+ try {
227
+ const response = await fetchImpl(METADATA_TOKEN_URL, {
228
+ method: "GET",
229
+ headers: { "Metadata-Flavor": "Google" },
230
+ signal: combined,
231
+ });
232
+ if (!response.ok) return undefined;
233
+ return (await response.json()) as TokenResponse;
234
+ } catch {
235
+ return undefined;
236
+ }
237
+ }
238
+
239
+ async function resolveAccessTokenUncached(signal: AbortSignal | undefined, fetchImpl: FetchImpl): Promise<{ source: string; token: TokenResponse }> {
240
+ const adc = loadAdcCredentials();
241
+ if (adc) {
242
+ const token = adc.creds.type === "service_account"
243
+ ? await exchangeJwtForToken(adc.creds, signal, fetchImpl)
244
+ : await exchangeRefreshToken(adc.creds, signal, fetchImpl);
245
+ return { source: adc.source, token };
246
+ }
247
+ const metadata = await fetchMetadataToken(signal, fetchImpl);
248
+ if (metadata) return { source: "metadata", token: metadata };
249
+ throw new Error(
250
+ "Vertex AI requires Application Default Credentials. Set GOOGLE_APPLICATION_CREDENTIALS, run `gcloud auth application-default login`, or run on a GCE/Cloud Run instance with a service account.",
251
+ );
252
+ }
253
+
254
+ /** Returns a Bearer access token for the `Authorization` header on Vertex AI calls (cached + refreshed). */
255
+ export async function getVertexAccessToken(options?: { signal?: AbortSignal; fetch?: FetchImpl }): Promise<string> {
256
+ const fetchImpl = options?.fetch ?? globalThis.fetch.bind(globalThis);
257
+ const skew = getRefreshSkewMs();
258
+ const now = Date.now();
259
+
260
+ // Only serve a cached token that matches the source the next resolve would actually use; prune
261
+ // expired or now-stale (different-source) entries so a credential-source change is honored.
262
+ const expectedSource = currentAdcSourceKey();
263
+ for (const [source, cached] of tokenCache) {
264
+ if (source === expectedSource && cached.expiresAtMs - skew > now) return cached.token;
265
+ if (cached.expiresAtMs - skew <= now) tokenCache.delete(source);
266
+ }
267
+
268
+ // Dedup in-flight fetches PER source, not globally: if the credential source changes while a
269
+ // fetch is in flight, a new caller must not reuse the old source's promise (cross-source bleed).
270
+ const cacheKey = expectedSource;
271
+ const existing = inflight.get(cacheKey);
272
+ if (existing) return existing;
273
+
274
+ const promise = (async () => {
275
+ try {
276
+ const { source, token } = await resolveAccessTokenUncached(options?.signal, fetchImpl);
277
+ const expiresAtMs = Date.now() + Math.max(0, token.expires_in * 1000);
278
+ tokenCache.set(source, { token: token.access_token, expiresAtMs });
279
+ return token.access_token;
280
+ } finally {
281
+ inflight.delete(cacheKey);
282
+ }
283
+ })();
284
+ inflight.set(cacheKey, promise);
285
+ return promise;
286
+ }
287
+
288
+ /** Test seam: clears every cached token + in-flight promise. */
289
+ export function __resetVertexTokenCache(): void {
290
+ tokenCache.clear();
291
+ inflight.clear();
292
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Google Antigravity (Cloud Code Assist) OAuth + project discovery.
3
+ *
4
+ * Mirrors CLIProxyAPI `internal/auth/antigravity/*`. Flow: standard Google OAuth (PKCE) → discover
5
+ * the Cloud Code Assist project via `loadCodeAssist`, onboarding via `onboardUser` when the account
6
+ * has no project yet. The discovered `projectId` is stored on the credential and injected into the
7
+ * CCA request envelope by the google adapter.
8
+ *
9
+ * The client id/secret below are the public OAuth client identifiers embedded in the Antigravity
10
+ * desktop client (overridable via env), not user secrets. Tokens/refresh are never logged.
11
+ */
12
+ import { OAuthCallbackFlow, type OAuthCallbackFlowOptions } from "./callback-server";
13
+ import { generatePKCE } from "./pkce";
14
+ import type { OAuthController, OAuthCredentials } from "./types";
15
+
16
+ const CLIENT_ID = process.env.GOOGLE_ANTIGRAVITY_CLIENT_ID
17
+ || "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
18
+ const CLIENT_SECRET = process.env.GOOGLE_ANTIGRAVITY_CLIENT_SECRET
19
+ || "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";
20
+ const AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
21
+ const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
22
+ const PROD_API = "https://cloudcode-pa.googleapis.com";
23
+ const DAILY_API = "https://daily-cloudcode-pa.googleapis.com";
24
+ const API_VERSION = "v1internal";
25
+ const SCOPES = [
26
+ "https://www.googleapis.com/auth/cloud-platform",
27
+ "https://www.googleapis.com/auth/userinfo.email",
28
+ "https://www.googleapis.com/auth/userinfo.profile",
29
+ "https://www.googleapis.com/auth/cclog",
30
+ "https://www.googleapis.com/auth/experimentsandconfigs",
31
+ ];
32
+ const CALLBACK_PORT = 51121;
33
+ const CALLBACK_PATH = "/callback";
34
+ const REFRESH_SKEW_MS = 50 * 60 * 1000; // refresh proactively ~50min before nominal 1h expiry
35
+ const REQUEST_TIMEOUT_MS = 30_000;
36
+ const ONBOARD_ATTEMPTS = 5;
37
+ const ONBOARD_POLL_MS = 2_000;
38
+
39
+ function requestSignal(signal: AbortSignal | undefined): AbortSignal {
40
+ const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
41
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
42
+ }
43
+
44
+ interface GoogleTokenPayload {
45
+ access_token?: unknown;
46
+ refresh_token?: unknown;
47
+ expires_in?: unknown;
48
+ id_token?: unknown;
49
+ }
50
+
51
+ function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
52
+ const part = token.split(".")[1];
53
+ if (!part) return undefined;
54
+ try {
55
+ return JSON.parse(Buffer.from(part, "base64url").toString("utf8")) as Record<string, unknown>;
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ }
60
+
61
+ function emailFromToken(accessToken: string, idToken: string | undefined): string | undefined {
62
+ const payload = (idToken ? decodeJwtPayload(idToken) : undefined) ?? decodeJwtPayload(accessToken);
63
+ const email = payload?.email;
64
+ return typeof email === "string" && email.length > 0 ? email.toLowerCase() : undefined;
65
+ }
66
+
67
+ async function postToken(body: Record<string, string>, signal?: AbortSignal): Promise<GoogleTokenPayload> {
68
+ const response = await fetch(TOKEN_ENDPOINT, {
69
+ method: "POST",
70
+ headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" },
71
+ body: new URLSearchParams(body).toString(),
72
+ signal: requestSignal(signal),
73
+ });
74
+ if (!response.ok) {
75
+ // Status only — the body can carry grant/account details.
76
+ throw new Error(`Antigravity token request failed: ${response.status}`);
77
+ }
78
+ return (await response.json()) as GoogleTokenPayload;
79
+ }
80
+
81
+ /** Pull a Cloud Code Assist project id out of a loadCodeAssist/onboardUser response shape. */
82
+ function extractProjectId(data: Record<string, unknown> | undefined): string | undefined {
83
+ if (!data) return undefined;
84
+ for (const key of ["cloudaicompanionProject", "projectId", "project"]) {
85
+ const value = data[key];
86
+ if (typeof value === "string" && value.length > 0) return value;
87
+ if (value && typeof value === "object" && typeof (value as { id?: unknown }).id === "string") {
88
+ return (value as { id: string }).id;
89
+ }
90
+ }
91
+ return undefined;
92
+ }
93
+
94
+ async function loadCodeAssistProject(accessToken: string, signal?: AbortSignal): Promise<string | undefined> {
95
+ const response = await fetch(`${PROD_API}/${API_VERSION}:loadCodeAssist`, {
96
+ method: "POST",
97
+ headers: { Authorization: `Bearer ${accessToken}`, Accept: "*/*", "Content-Type": "application/json" },
98
+ body: JSON.stringify({ metadata: { ideType: "ANTIGRAVITY" } }),
99
+ signal: requestSignal(signal),
100
+ });
101
+ if (!response.ok) return undefined;
102
+ return extractProjectId((await response.json().catch(() => undefined)) as Record<string, unknown> | undefined);
103
+ }
104
+
105
+ async function onboardProject(accessToken: string, signal?: AbortSignal): Promise<string | undefined> {
106
+ for (let attempt = 0; attempt < ONBOARD_ATTEMPTS; attempt++) {
107
+ if (signal?.aborted) throw signal.reason ?? new Error("Antigravity onboarding aborted");
108
+ const response = await fetch(`${DAILY_API}/${API_VERSION}:onboardUser`, {
109
+ method: "POST",
110
+ headers: { Authorization: `Bearer ${accessToken}`, Accept: "*/*", "Content-Type": "application/json" },
111
+ body: JSON.stringify({ tier_id: "free-tier", metadata: { ide_type: "ANTIGRAVITY", ide_name: "antigravity" } }),
112
+ signal: requestSignal(signal),
113
+ });
114
+ if (!response.ok) {
115
+ // Transient (429/5xx): keep polling within the attempt budget. Hard 4xx: give up now.
116
+ if (response.status === 429 || response.status >= 500) {
117
+ await new Promise(resolve => setTimeout(resolve, ONBOARD_POLL_MS));
118
+ continue;
119
+ }
120
+ return undefined;
121
+ }
122
+ const data = (await response.json().catch(() => ({}))) as Record<string, unknown>;
123
+ if (data.done === true) {
124
+ return extractProjectId(data.response as Record<string, unknown> | undefined);
125
+ }
126
+ await new Promise(resolve => setTimeout(resolve, ONBOARD_POLL_MS));
127
+ }
128
+ return undefined;
129
+ }
130
+
131
+ /** Discover the CCA project for an access token (loadCodeAssist → onboardUser fallback). */
132
+ export async function discoverAntigravityProject(accessToken: string, signal?: AbortSignal): Promise<string | undefined> {
133
+ return (await loadCodeAssistProject(accessToken, signal)) ?? (await onboardProject(accessToken, signal));
134
+ }
135
+
136
+ function credentialsFromPayload(payload: GoogleTokenPayload, refreshFallback = ""): OAuthCredentials {
137
+ if (typeof payload.access_token !== "string" || payload.access_token.length === 0) {
138
+ throw new Error("Antigravity token response did not include an access token");
139
+ }
140
+ const refresh = typeof payload.refresh_token === "string" && payload.refresh_token.length > 0
141
+ ? payload.refresh_token
142
+ : refreshFallback;
143
+ if (!refresh) throw new Error("Antigravity token response did not include a refresh token");
144
+ const expiresIn = typeof payload.expires_in === "number" && Number.isFinite(payload.expires_in) ? payload.expires_in : 3600;
145
+ const idToken = typeof payload.id_token === "string" ? payload.id_token : undefined;
146
+ return {
147
+ refresh,
148
+ access: payload.access_token,
149
+ expires: Date.now() + expiresIn * 1000 - REFRESH_SKEW_MS,
150
+ email: emailFromToken(payload.access_token, idToken),
151
+ };
152
+ }
153
+
154
+ class AntigravityOAuthFlow extends OAuthCallbackFlow {
155
+ #verifier = "";
156
+
157
+ constructor(ctrl: OAuthController) {
158
+ super(ctrl, {
159
+ preferredPort: CALLBACK_PORT,
160
+ callbackPath: CALLBACK_PATH,
161
+ callbackHostname: "127.0.0.1",
162
+ callbackBindHostname: "127.0.0.1",
163
+ redirectUri: `http://127.0.0.1:${CALLBACK_PORT}${CALLBACK_PATH}`,
164
+ } satisfies OAuthCallbackFlowOptions);
165
+ }
166
+
167
+ async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
168
+ const pkce = await generatePKCE();
169
+ this.#verifier = pkce.verifier;
170
+ const params = new URLSearchParams({
171
+ response_type: "code",
172
+ client_id: CLIENT_ID,
173
+ redirect_uri: redirectUri,
174
+ scope: SCOPES.join(" "),
175
+ code_challenge: pkce.challenge,
176
+ code_challenge_method: "S256",
177
+ access_type: "offline",
178
+ prompt: "consent",
179
+ state,
180
+ });
181
+ return {
182
+ url: `${AUTH_ENDPOINT}?${params.toString()}`,
183
+ instructions: "Complete Google (Antigravity) login in your browser, then paste the redirect URL or code if prompted.",
184
+ };
185
+ }
186
+
187
+ async exchangeToken(code: string, _state: string, redirectUri: string): Promise<OAuthCredentials> {
188
+ if (!this.#verifier) throw new Error("Antigravity OAuth PKCE verifier was not initialized");
189
+ const payload = await postToken({
190
+ grant_type: "authorization_code",
191
+ client_id: CLIENT_ID,
192
+ client_secret: CLIENT_SECRET,
193
+ code,
194
+ redirect_uri: redirectUri,
195
+ code_verifier: this.#verifier,
196
+ }, this.ctrl.signal);
197
+ const creds = credentialsFromPayload(payload);
198
+ this.ctrl.onProgress?.("Discovering Cloud Code Assist project");
199
+ const projectId = await discoverAntigravityProject(creds.access, this.ctrl.signal);
200
+ if (!projectId) {
201
+ // Fail the login rather than persisting a credential that every request would reject for a
202
+ // missing CCA project — otherwise status shows "logged in" while all calls fail closed.
203
+ throw new Error("Antigravity login could not discover a Cloud Code Assist project for this account. Ensure the account has Antigravity/Cloud Code Assist access and try again.");
204
+ }
205
+ return { ...creds, projectId };
206
+ }
207
+ }
208
+
209
+ export async function loginAntigravity(ctrl: OAuthController): Promise<OAuthCredentials> {
210
+ return new AntigravityOAuthFlow(ctrl).login();
211
+ }
212
+
213
+ export async function refreshAntigravityToken(refreshToken: string, signal?: AbortSignal): Promise<OAuthCredentials> {
214
+ if (!refreshToken) throw new Error("Antigravity credentials are expired and do not include a refresh token");
215
+ const payload = await postToken({
216
+ grant_type: "refresh_token",
217
+ client_id: CLIENT_ID,
218
+ client_secret: CLIENT_SECRET,
219
+ refresh_token: refreshToken,
220
+ }, signal);
221
+ const creds = credentialsFromPayload(payload, refreshToken);
222
+ // Re-discover the project on refresh so a newly-onboarded account fills in projectId.
223
+ const projectId = await discoverAntigravityProject(creds.access, signal).catch(() => undefined);
224
+ return projectId ? { ...creds, projectId } : creds;
225
+ }
@@ -8,6 +8,7 @@ import { ANTHROPIC_OAUTH_BETA, loginAnthropic, refreshAnthropicToken } from "./a
8
8
  import { loginKimi, refreshKimiToken } from "./kimi";
9
9
  import { loginKiro, readKiroCliSqlite, refreshKiroToken } from "./kiro";
10
10
  import { loginChatGPT, refreshChatGPTToken } from "./chatgpt";
11
+ import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity";
11
12
  import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
12
13
 
13
14
  const REFRESH_SKEW_MS = 60_000;
@@ -60,6 +61,12 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
60
61
  providerConfig: oauthConfig("kiro"),
61
62
  defaultModel: oauthDefaultModel("kiro"),
62
63
  },
64
+ "google-antigravity": {
65
+ login: (ctrl) => loginAntigravity(ctrl),
66
+ refresh: refreshAntigravityToken,
67
+ providerConfig: oauthConfig("google-antigravity"),
68
+ defaultModel: oauthDefaultModel("google-antigravity"),
69
+ },
63
70
  chatgpt: {
64
71
  login: loginChatGPT,
65
72
  refresh: (rt) => refreshChatGPTToken(rt),
@@ -72,6 +79,11 @@ export function isOAuthProvider(name: string): boolean {
72
79
  return name in OAUTH_PROVIDERS;
73
80
  }
74
81
 
82
+ /** The discovered project id stored on an OAuth credential (Antigravity CCA), if any. */
83
+ export function getOAuthCredentialProjectId(provider: string): string | undefined {
84
+ return getCredential(provider)?.projectId;
85
+ }
86
+
75
87
  /** Provider ids that support real OAuth login (drives the GUI's "Log in with …" buttons). */
76
88
  export function listOAuthProviders(): string[] {
77
89
  return Object.keys(OAUTH_PROVIDERS);
@@ -127,7 +139,13 @@ async function refreshAndPersistAccessToken(
127
139
  }
128
140
  try {
129
141
  const fresh = await def.refresh(cred.refresh);
130
- saveCredential(provider, { ...fresh, source: fresh.source ?? cred.source ?? "oauth" });
142
+ saveCredential(provider, {
143
+ ...fresh,
144
+ source: fresh.source ?? cred.source ?? "oauth",
145
+ // Preserve a previously-discovered project id when a refresh-time re-discovery comes back empty
146
+ // (e.g. a transient network blip), so Antigravity does not lose its CCA project across refresh.
147
+ ...(fresh.projectId === undefined && cred.projectId ? { projectId: cred.projectId } : {}),
148
+ });
131
149
  return fresh.access;
132
150
  } catch (err) {
133
151
  if (provider === "kiro") {
@@ -51,6 +51,7 @@ function normalizeCredential(cred: unknown): OAuthCredentials | null {
51
51
  if (typeof candidate.email === "string" && candidate.email.length > 0) normalized.email = candidate.email;
52
52
  if (typeof candidate.accountId === "string" && candidate.accountId.length > 0) normalized.accountId = candidate.accountId;
53
53
  if (isCredentialSource(candidate.source)) normalized.source = candidate.source;
54
+ if (typeof candidate.projectId === "string" && candidate.projectId.length > 0) normalized.projectId = candidate.projectId;
54
55
  return normalized;
55
56
  }
56
57
 
@@ -8,6 +8,8 @@ export type OAuthCredentials = {
8
8
  email?: string;
9
9
  accountId?: string;
10
10
  source?: OAuthCredentialSource;
11
+ /** Google Antigravity (Cloud Code Assist) discovered project id; injected into the CCA envelope. */
12
+ projectId?: string;
11
13
  };
12
14
 
13
15
  export interface OAuthController {
@@ -27,10 +27,39 @@ export function applyProviderContextCap(contextWindow: number | undefined, cap:
27
27
  return contextWindow > cap ? cap : contextWindow;
28
28
  }
29
29
 
30
+ /** Effective global cap value: explicit config value, else the built-in default. */
31
+ export function globalContextCapValue(config: Pick<OcxConfig, "contextCapValue">): number {
32
+ const value = config.contextCapValue;
33
+ return isValidContextCap(value) ? Math.floor(value) : DEFAULT_PROVIDER_CONTEXT_CAP;
34
+ }
35
+
30
36
  export function setProviderContextCap(config: OcxConfig, provider: string, enabled: boolean): void {
31
37
  const next = providerContextCaps(config);
32
- if (enabled) next[provider] = DEFAULT_PROVIDER_CONTEXT_CAP;
38
+ if (enabled) next[provider] = globalContextCapValue(config);
33
39
  else delete next[provider];
34
40
  if (Object.keys(next).length > 0) config.providerContextCaps = next;
35
41
  else delete config.providerContextCaps;
36
42
  }
43
+
44
+ /** Set the global cap value and re-point every already-enabled provider to it. */
45
+ export function setGlobalContextCapValue(config: OcxConfig, value: number): void {
46
+ if (!isValidContextCap(value)) return;
47
+ const next = Math.floor(value);
48
+ config.contextCapValue = next;
49
+ const caps = providerContextCaps(config);
50
+ for (const provider of Object.keys(caps)) caps[provider] = next;
51
+ if (Object.keys(caps).length > 0) config.providerContextCaps = caps;
52
+ }
53
+
54
+ /** Enable the cap for every named provider at the current value, or clear all caps. */
55
+ export function setAllProviderContextCaps(config: OcxConfig, providerNames: string[], enabled: boolean): void {
56
+ if (!enabled) {
57
+ delete config.providerContextCaps;
58
+ return;
59
+ }
60
+ const value = globalContextCapValue(config);
61
+ const next: Record<string, number> = {};
62
+ for (const name of providerNames) next[name] = value;
63
+ if (Object.keys(next).length > 0) config.providerContextCaps = next;
64
+ else delete config.providerContextCaps;
65
+ }
@@ -0,0 +1,30 @@
1
+ // Google Antigravity (Cloud Code Assist) bundled model list.
2
+ //
3
+ // Single source of truth: the Antigravity `:fetchAvailableModels` backend, the same one the `agy`
4
+ // CLI resolves labels against. The 8 ids below are the WIRE ids (not the human labels) that the CCA
5
+ // `streamGenerateContent` envelope's `model` field accepts, each verified to return 200 live. The
6
+ // effort tier is baked into the id by the backend (e.g. "Gemini 3.1 Pro (High)" => gemini-pro-agent),
7
+ // so opencodex must send these exact strings, not the label-shaped guesses. Antigravity's OAuth
8
+ // backend has no OpenAI-style `GET /models`, so this static list is what surfaces in the picker.
9
+ export const ANTIGRAVITY_MODELS = [
10
+ "gemini-3.5-flash-low",
11
+ "gemini-3-flash-agent",
12
+ "gemini-3.5-flash-extra-low",
13
+ "gemini-3.1-pro-low",
14
+ "gemini-pro-agent",
15
+ "claude-sonnet-4-6",
16
+ "claude-opus-4-6-thinking",
17
+ "gpt-oss-120b-medium",
18
+ ];
19
+
20
+ // Context windows from the upstream `:fetchAvailableModels` maxTokens per model.
21
+ export const ANTIGRAVITY_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
22
+ "gemini-3.5-flash-low": 1_048_576,
23
+ "gemini-3-flash-agent": 1_048_576,
24
+ "gemini-3.5-flash-extra-low": 1_048_576,
25
+ "gemini-3.1-pro-low": 1_048_576,
26
+ "gemini-pro-agent": 1_048_576,
27
+ "claude-sonnet-4-6": 200_000,
28
+ "claude-opus-4-6-thinking": 1_000_000,
29
+ "gpt-oss-120b-medium": 131_072,
30
+ };
@@ -81,6 +81,9 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
81
81
  ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}),
82
82
  ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}),
83
83
  ...(entry.escapeBuiltinToolNames !== undefined ? { escapeBuiltinToolNames: entry.escapeBuiltinToolNames } : {}),
84
+ ...(entry.googleMode ? { googleMode: entry.googleMode } : {}),
85
+ ...(entry.project ? { project: entry.project } : {}),
86
+ ...(entry.location ? { location: entry.location } : {}),
84
87
  };
85
88
  }
86
89
 
@@ -111,6 +114,9 @@ export function deriveKeyLoginMap(): Record<string, DerivedKeyLoginProvider> {
111
114
  ...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}),
112
115
  ...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}),
113
116
  ...(entry.escapeBuiltinToolNames !== undefined ? { escapeBuiltinToolNames: entry.escapeBuiltinToolNames } : {}),
117
+ ...(entry.googleMode ? { googleMode: entry.googleMode } : {}),
118
+ ...(entry.project ? { project: entry.project } : {}),
119
+ ...(entry.location ? { location: entry.location } : {}),
114
120
  };
115
121
  }
116
122
  return out;