opencode-gemini-auth 1.3.8 → 1.3.10

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.
package/src/plugin.ts CHANGED
@@ -3,13 +3,15 @@ import { spawn } from "node:child_process";
3
3
  import { GEMINI_PROVIDER_ID, GEMINI_REDIRECT_URI } from "./constants";
4
4
  import {
5
5
  authorizeGemini,
6
- exchangeGemini,
7
6
  exchangeGeminiWithVerifier,
8
7
  } from "./gemini/oauth";
9
8
  import type { GeminiTokenExchangeResult } from "./gemini/oauth";
10
9
  import { accessTokenExpired, isOAuthAuth } from "./plugin/auth";
11
- import { ensureProjectContext } from "./plugin/project";
12
- import { startGeminiDebugRequest } from "./plugin/debug";
10
+ import {
11
+ ensureProjectContext,
12
+ resolveProjectContextFromAccessToken,
13
+ } from "./plugin/project";
14
+ import { isGeminiDebugEnabled, logGeminiDebugMessage, startGeminiDebugRequest } from "./plugin/debug";
13
15
  import {
14
16
  isGenerativeLanguageRequest,
15
17
  prepareGeminiRequest,
@@ -20,6 +22,7 @@ import { startOAuthListener, type OAuthListener } from "./plugin/server";
20
22
  import type {
21
23
  GetAuth,
22
24
  LoaderResult,
25
+ OAuthAuthDetails,
23
26
  PluginContext,
24
27
  PluginResult,
25
28
  ProjectContextResult,
@@ -50,7 +53,12 @@ export const GeminiCLIOAuthPlugin = async (
50
53
  ? providerOptions.projectId.trim()
51
54
  : "";
52
55
  const projectIdFromEnv = process.env.OPENCODE_GEMINI_PROJECT_ID?.trim() ?? "";
53
- const configuredProjectId = projectIdFromEnv || projectIdFromConfig || undefined;
56
+ const googleProjectIdFromEnv =
57
+ process.env.GOOGLE_CLOUD_PROJECT?.trim() ??
58
+ process.env.GOOGLE_CLOUD_PROJECT_ID?.trim() ??
59
+ "";
60
+ const configuredProjectId =
61
+ projectIdFromEnv || projectIdFromConfig || googleProjectIdFromEnv || undefined;
54
62
 
55
63
  if (provider.models) {
56
64
  for (const model of Object.values(provider.models)) {
@@ -136,6 +144,57 @@ export const GeminiCLIOAuthPlugin = async (
136
144
  label: "OAuth with Google (Gemini CLI)",
137
145
  type: "oauth",
138
146
  authorize: async () => {
147
+ const maybeHydrateProjectId = async (
148
+ result: GeminiTokenExchangeResult,
149
+ ): Promise<GeminiTokenExchangeResult> => {
150
+ if (result.type !== "success") {
151
+ return result;
152
+ }
153
+
154
+ const accessToken = result.access;
155
+ if (!accessToken) {
156
+ return result;
157
+ }
158
+
159
+ const projectFromEnv = process.env.OPENCODE_GEMINI_PROJECT_ID?.trim() ?? "";
160
+ const googleProjectFromEnv =
161
+ process.env.GOOGLE_CLOUD_PROJECT?.trim() ??
162
+ process.env.GOOGLE_CLOUD_PROJECT_ID?.trim() ??
163
+ "";
164
+ const configuredProjectId =
165
+ projectFromEnv || googleProjectFromEnv || undefined;
166
+
167
+ try {
168
+ const authSnapshot = {
169
+ type: "oauth",
170
+ refresh: result.refresh,
171
+ access: result.access,
172
+ expires: result.expires,
173
+ } satisfies OAuthAuthDetails;
174
+ const projectContext = await resolveProjectContextFromAccessToken(
175
+ authSnapshot,
176
+ accessToken,
177
+ configuredProjectId,
178
+ );
179
+
180
+ if (projectContext.auth.refresh !== result.refresh) {
181
+ if (isGeminiDebugEnabled()) {
182
+ logGeminiDebugMessage(
183
+ `OAuth project resolved during auth: ${projectContext.effectiveProjectId || "none"}`,
184
+ );
185
+ }
186
+ return { ...result, refresh: projectContext.auth.refresh };
187
+ }
188
+ } catch (error) {
189
+ if (isGeminiDebugEnabled()) {
190
+ const message = error instanceof Error ? error.message : String(error);
191
+ console.warn(`[Gemini OAuth] Project resolution skipped: ${message}`);
192
+ }
193
+ }
194
+
195
+ return result;
196
+ };
197
+
139
198
  const isHeadless = !!(
140
199
  process.env.SSH_CONNECTION ||
141
200
  process.env.SSH_CLIENT ||
@@ -188,7 +247,16 @@ export const GeminiCLIOAuthPlugin = async (
188
247
  };
189
248
  }
190
249
 
191
- return await exchangeGemini(code, state);
250
+ if (state !== authorization.state) {
251
+ return {
252
+ type: "failed",
253
+ error: "State mismatch in callback URL (possible CSRF attempt)",
254
+ };
255
+ }
256
+
257
+ return await maybeHydrateProjectId(
258
+ await exchangeGeminiWithVerifier(code, authorization.verifier),
259
+ );
192
260
  } catch (error) {
193
261
  return {
194
262
  type: "failed",
@@ -208,10 +276,10 @@ export const GeminiCLIOAuthPlugin = async (
208
276
  url: authorization.url,
209
277
  instructions:
210
278
  "Complete OAuth in your browser, then paste the full redirected URL (e.g., http://localhost:8085/oauth2callback?code=...&state=...) or just the authorization code.",
211
- method: "code",
212
- callback: async (callbackUrl: string): Promise<GeminiTokenExchangeResult> => {
213
- try {
214
- const { code, state } = parseOAuthCallbackInput(callbackUrl);
279
+ method: "code",
280
+ callback: async (callbackUrl: string): Promise<GeminiTokenExchangeResult> => {
281
+ try {
282
+ const { code, state } = parseOAuthCallbackInput(callbackUrl);
215
283
 
216
284
  if (!code) {
217
285
  return {
@@ -220,11 +288,16 @@ export const GeminiCLIOAuthPlugin = async (
220
288
  };
221
289
  }
222
290
 
223
- if (state) {
224
- return exchangeGemini(code, state);
291
+ if (state && state !== authorization.state) {
292
+ return {
293
+ type: "failed",
294
+ error: "State mismatch in callback input (possible CSRF attempt)",
295
+ };
225
296
  }
226
297
 
227
- return exchangeGeminiWithVerifier(code, authorization.verifier);
298
+ return await maybeHydrateProjectId(
299
+ await exchangeGeminiWithVerifier(code, authorization.verifier),
300
+ );
228
301
  } catch (error) {
229
302
  return {
230
303
  type: "failed",
@@ -250,6 +323,11 @@ const RETRYABLE_STATUS_CODES = new Set([429, 503]);
250
323
  const DEFAULT_MAX_RETRIES = 2;
251
324
  const DEFAULT_BASE_DELAY_MS = 800;
252
325
  const DEFAULT_MAX_DELAY_MS = 8000;
326
+ const CLOUDCODE_DOMAINS = [
327
+ "cloudcode-pa.googleapis.com",
328
+ "staging-cloudcode-pa.googleapis.com",
329
+ "autopush-cloudcode-pa.googleapis.com",
330
+ ];
253
331
 
254
332
  function toUrlString(value: RequestInfo): string {
255
333
  if (typeof value === "string") {
@@ -314,6 +392,10 @@ function openBrowserUrl(url: string): void {
314
392
  }
315
393
  }
316
394
 
395
+ /**
396
+ * Sends requests with bounded retry logic for transient Cloud Code failures.
397
+ * Mirrors the Gemini CLI handling of Code Assist rate-limit signals.
398
+ */
317
399
  async function fetchWithRetry(input: RequestInfo, init: RequestInit | undefined): Promise<Response> {
318
400
  const maxRetries = DEFAULT_MAX_RETRIES;
319
401
  const baseDelayMs = DEFAULT_BASE_DELAY_MS;
@@ -330,7 +412,22 @@ async function fetchWithRetry(input: RequestInfo, init: RequestInit | undefined)
330
412
  return response;
331
413
  }
332
414
 
333
- const delayMs = await getRetryDelayMs(response, attempt, baseDelayMs, maxDelayMs);
415
+ let retryDelayMs: number | null = null;
416
+ if (response.status === 429) {
417
+ const quotaContext = await classifyQuotaResponse(response);
418
+ if (quotaContext?.terminal) {
419
+ return response;
420
+ }
421
+ retryDelayMs = quotaContext?.retryDelayMs ?? null;
422
+ }
423
+
424
+ const delayMs = await getRetryDelayMs(
425
+ response,
426
+ attempt,
427
+ baseDelayMs,
428
+ maxDelayMs,
429
+ retryDelayMs,
430
+ );
334
431
  if (!delayMs || delayMs <= 0) {
335
432
  return response;
336
433
  }
@@ -369,11 +466,16 @@ function canRetryRequest(init: RequestInit | undefined): boolean {
369
466
  return false;
370
467
  }
371
468
 
469
+ /**
470
+ * Resolves a retry delay from headers, body hints, or exponential backoff.
471
+ * Honors RetryInfo/Retry-After hints emitted by Code Assist.
472
+ */
372
473
  async function getRetryDelayMs(
373
474
  response: Response,
374
475
  attempt: number,
375
476
  baseDelayMs: number,
376
477
  maxDelayMs: number,
478
+ bodyDelayMs: number | null = null,
377
479
  ): Promise<number | null> {
378
480
  const headerDelayMs = parseRetryAfterMs(response.headers.get("retry-after-ms"));
379
481
  if (headerDelayMs !== null) {
@@ -385,9 +487,9 @@ async function getRetryDelayMs(
385
487
  return clampDelay(retryAfter, maxDelayMs);
386
488
  }
387
489
 
388
- const bodyDelayMs = await parseRetryDelayFromBody(response);
389
- if (bodyDelayMs !== null) {
390
- return clampDelay(bodyDelayMs, maxDelayMs);
490
+ const parsedBodyDelayMs = bodyDelayMs ?? (await parseRetryDelayFromBody(response));
491
+ if (parsedBodyDelayMs !== null) {
492
+ return clampDelay(parsedBodyDelayMs, maxDelayMs);
391
493
  }
392
494
 
393
495
  const fallback = baseDelayMs * Math.pow(2, attempt);
@@ -452,7 +554,8 @@ async function parseRetryDelayFromBody(response: Response): Promise<number | nul
452
554
 
453
555
  const details = parsed?.error?.details;
454
556
  if (!Array.isArray(details)) {
455
- return null;
557
+ const message = typeof parsed?.error?.message === "string" ? parsed.error.message : "";
558
+ return parseRetryDelayFromMessage(message);
456
559
  }
457
560
 
458
561
  for (const detail of details) {
@@ -469,7 +572,8 @@ async function parseRetryDelayFromBody(response: Response): Promise<number | nul
469
572
  }
470
573
  }
471
574
 
472
- return null;
575
+ const message = typeof parsed?.error?.message === "string" ? parsed.error.message : "";
576
+ return parseRetryDelayFromMessage(message);
473
577
  }
474
578
 
475
579
  function parseRetryDelayValue(value: unknown): number | null {
@@ -503,6 +607,91 @@ function parseRetryDelayValue(value: unknown): number | null {
503
607
  return null;
504
608
  }
505
609
 
610
+ /**
611
+ * Parses retry delays embedded in error message strings (e.g., "Please retry in 5s").
612
+ */
613
+ function parseRetryDelayFromMessage(message: string): number | null {
614
+ if (!message) {
615
+ return null;
616
+ }
617
+ const retryMatch = message.match(/Please retry in ([0-9.]+(?:ms|s))/);
618
+ if (retryMatch?.[1]) {
619
+ return parseRetryDelayValue(retryMatch[1]);
620
+ }
621
+ const afterMatch = message.match(/after\s+([0-9.]+(?:ms|s))/i);
622
+ if (afterMatch?.[1]) {
623
+ return parseRetryDelayValue(afterMatch[1]);
624
+ }
625
+ return null;
626
+ }
627
+
628
+ /**
629
+ * Classifies quota errors as terminal vs retryable and extracts retry hints.
630
+ * Matches Gemini CLI semantics: QUOTA_EXHAUSTED is terminal, RATE_LIMIT_EXCEEDED is retryable.
631
+ */
632
+ async function classifyQuotaResponse(
633
+ response: Response,
634
+ ): Promise<{ terminal: boolean; retryDelayMs?: number } | null> {
635
+ let text = "";
636
+ try {
637
+ text = await response.clone().text();
638
+ } catch {
639
+ return null;
640
+ }
641
+
642
+ if (!text) {
643
+ return null;
644
+ }
645
+
646
+ let parsed: any;
647
+ try {
648
+ parsed = JSON.parse(text);
649
+ } catch {
650
+ return null;
651
+ }
652
+
653
+ const error = parsed?.error ?? {};
654
+ const details = Array.isArray(error?.details) ? error.details : [];
655
+ const retryDelayMs = parseRetryDelayFromMessage(error?.message ?? "") ?? undefined;
656
+
657
+ const errorInfo = details.find(
658
+ (detail: any) =>
659
+ detail &&
660
+ typeof detail === "object" &&
661
+ detail["@type"] === "type.googleapis.com/google.rpc.ErrorInfo",
662
+ );
663
+
664
+ if (errorInfo?.domain && !CLOUDCODE_DOMAINS.includes(errorInfo.domain)) {
665
+ return null;
666
+ }
667
+
668
+ if (errorInfo?.reason === "QUOTA_EXHAUSTED") {
669
+ return { terminal: true, retryDelayMs };
670
+ }
671
+ if (errorInfo?.reason === "RATE_LIMIT_EXCEEDED") {
672
+ return { terminal: false, retryDelayMs };
673
+ }
674
+
675
+ const quotaFailure = details.find(
676
+ (detail: any) =>
677
+ detail &&
678
+ typeof detail === "object" &&
679
+ detail["@type"] === "type.googleapis.com/google.rpc.QuotaFailure",
680
+ );
681
+
682
+ if (quotaFailure?.violations && Array.isArray(quotaFailure.violations)) {
683
+ const combined = quotaFailure.violations
684
+ .map((violation: any) => String(violation?.description ?? "").toLowerCase())
685
+ .join(" ");
686
+ if (combined.includes("daily") || combined.includes("per day")) {
687
+ return { terminal: true, retryDelayMs };
688
+ }
689
+ return { terminal: false, retryDelayMs };
690
+ }
691
+
692
+ return { terminal: false, retryDelayMs };
693
+ }
694
+
506
695
  function wait(ms: number): Promise<void> {
507
696
  return new Promise((resolve) => {
508
697
  setTimeout(resolve, ms);