opencode-antigravity-auth 1.0.7 → 1.1.1

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.
@@ -1,13 +1,191 @@
1
1
  import { exec } from "node:child_process";
2
- import { ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_PROVIDER_ID, ANTIGRAVITY_REDIRECT_URI, } from "./constants";
2
+ import { ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_PROVIDER_ID } from "./constants";
3
3
  import { authorizeAntigravity, exchangeAntigravity } from "./antigravity/oauth";
4
- import { accessTokenExpired, isOAuthAuth } from "./plugin/auth";
5
- import { promptProjectId } from "./plugin/cli";
4
+ import { accessTokenExpired, isOAuthAuth, parseRefreshParts } from "./plugin/auth";
5
+ import { promptAddAnotherAccount, promptLoginMode, promptProjectId } from "./plugin/cli";
6
6
  import { ensureProjectContext } from "./plugin/project";
7
7
  import { startAntigravityDebugRequest } from "./plugin/debug";
8
8
  import { isGenerativeLanguageRequest, prepareAntigravityRequest, transformAntigravityResponse, } from "./plugin/request";
9
- import { refreshAccessToken } from "./plugin/token";
9
+ import { AntigravityTokenRefreshError, refreshAccessToken } from "./plugin/token";
10
10
  import { startOAuthListener } from "./plugin/server";
11
+ import { clearAccounts, loadAccounts, saveAccounts } from "./plugin/storage";
12
+ import { AccountManager } from "./plugin/accounts";
13
+ const MAX_OAUTH_ACCOUNTS = 10;
14
+ async function openBrowser(url) {
15
+ try {
16
+ if (process.platform === "darwin") {
17
+ exec(`open "${url}"`);
18
+ return;
19
+ }
20
+ if (process.platform === "win32") {
21
+ exec(`start "${url}"`);
22
+ return;
23
+ }
24
+ exec(`xdg-open "${url}"`);
25
+ }
26
+ catch {
27
+ // ignore
28
+ }
29
+ }
30
+ async function promptOAuthCallbackValue(message) {
31
+ const { createInterface } = await import("node:readline/promises");
32
+ const { stdin, stdout } = await import("node:process");
33
+ const rl = createInterface({ input: stdin, output: stdout });
34
+ try {
35
+ return (await rl.question(message)).trim();
36
+ }
37
+ finally {
38
+ rl.close();
39
+ }
40
+ }
41
+ function getStateFromAuthorizationUrl(authorizationUrl) {
42
+ try {
43
+ return new URL(authorizationUrl).searchParams.get("state") ?? "";
44
+ }
45
+ catch {
46
+ return "";
47
+ }
48
+ }
49
+ function extractOAuthCallbackParams(url) {
50
+ const code = url.searchParams.get("code");
51
+ const state = url.searchParams.get("state");
52
+ if (!code || !state) {
53
+ return null;
54
+ }
55
+ return { code, state };
56
+ }
57
+ function parseOAuthCallbackInput(value, fallbackState) {
58
+ const trimmed = value.trim();
59
+ if (!trimmed) {
60
+ return { error: "Missing authorization code" };
61
+ }
62
+ try {
63
+ const url = new URL(trimmed);
64
+ const code = url.searchParams.get("code");
65
+ const state = url.searchParams.get("state") ?? fallbackState;
66
+ if (!code) {
67
+ return { error: "Missing code in callback URL" };
68
+ }
69
+ if (!state) {
70
+ return { error: "Missing state in callback URL" };
71
+ }
72
+ return { code, state };
73
+ }
74
+ catch {
75
+ if (!fallbackState) {
76
+ return { error: "Missing state. Paste the full redirect URL instead of only the code." };
77
+ }
78
+ return { code: trimmed, state: fallbackState };
79
+ }
80
+ }
81
+ function clampInt(value, min, max) {
82
+ if (!Number.isFinite(value)) {
83
+ return min;
84
+ }
85
+ return Math.min(max, Math.max(min, Math.floor(value)));
86
+ }
87
+ async function persistAccountPool(results, replaceAll = false) {
88
+ if (results.length === 0) {
89
+ return;
90
+ }
91
+ const now = Date.now();
92
+ // If replaceAll is true (fresh login), start with empty accounts
93
+ // Otherwise, load existing accounts and merge
94
+ const stored = replaceAll ? null : await loadAccounts();
95
+ const accounts = stored?.accounts ? [...stored.accounts] : [];
96
+ const indexByRefreshToken = new Map();
97
+ for (let i = 0; i < accounts.length; i++) {
98
+ const token = accounts[i]?.refreshToken;
99
+ if (token) {
100
+ indexByRefreshToken.set(token, i);
101
+ }
102
+ }
103
+ for (const result of results) {
104
+ const parts = parseRefreshParts(result.refresh);
105
+ if (!parts.refreshToken) {
106
+ continue;
107
+ }
108
+ const existingIndex = indexByRefreshToken.get(parts.refreshToken);
109
+ if (existingIndex === undefined) {
110
+ indexByRefreshToken.set(parts.refreshToken, accounts.length);
111
+ accounts.push({
112
+ email: result.email,
113
+ refreshToken: parts.refreshToken,
114
+ projectId: parts.projectId,
115
+ managedProjectId: parts.managedProjectId,
116
+ addedAt: now,
117
+ lastUsed: now,
118
+ isRateLimited: false,
119
+ rateLimitResetTime: 0,
120
+ });
121
+ continue;
122
+ }
123
+ const existing = accounts[existingIndex];
124
+ if (!existing) {
125
+ continue;
126
+ }
127
+ accounts[existingIndex] = {
128
+ ...existing,
129
+ email: result.email ?? existing.email,
130
+ projectId: parts.projectId ?? existing.projectId,
131
+ managedProjectId: parts.managedProjectId ?? existing.managedProjectId,
132
+ lastUsed: now,
133
+ };
134
+ }
135
+ if (accounts.length === 0) {
136
+ return;
137
+ }
138
+ // For fresh logins, always start at index 0
139
+ const activeIndex = replaceAll
140
+ ? 0
141
+ : (typeof stored?.activeIndex === "number" && Number.isFinite(stored.activeIndex) ? stored.activeIndex : 0);
142
+ await saveAccounts({
143
+ version: 1,
144
+ accounts,
145
+ activeIndex: clampInt(activeIndex, 0, accounts.length - 1),
146
+ });
147
+ }
148
+ function retryAfterMsFromResponse(response) {
149
+ const retryAfterMsHeader = response.headers.get("retry-after-ms");
150
+ if (retryAfterMsHeader) {
151
+ const parsed = Number.parseInt(retryAfterMsHeader, 10);
152
+ if (!Number.isNaN(parsed) && parsed > 0) {
153
+ return parsed;
154
+ }
155
+ }
156
+ const retryAfterHeader = response.headers.get("retry-after");
157
+ if (retryAfterHeader) {
158
+ const parsed = Number.parseInt(retryAfterHeader, 10);
159
+ if (!Number.isNaN(parsed) && parsed > 0) {
160
+ return parsed * 1000;
161
+ }
162
+ }
163
+ return 60_000;
164
+ }
165
+ /**
166
+ * Sleep for a given number of milliseconds, respecting an abort signal.
167
+ */
168
+ function sleep(ms, signal) {
169
+ return new Promise((resolve, reject) => {
170
+ if (signal?.aborted) {
171
+ reject(signal.reason instanceof Error ? signal.reason : new Error("Aborted"));
172
+ return;
173
+ }
174
+ const timeout = setTimeout(() => {
175
+ cleanup();
176
+ resolve();
177
+ }, ms);
178
+ const onAbort = () => {
179
+ cleanup();
180
+ reject(signal?.reason instanceof Error ? signal.reason : new Error("Aborted"));
181
+ };
182
+ const cleanup = () => {
183
+ clearTimeout(timeout);
184
+ signal?.removeEventListener("abort", onAbort);
185
+ };
186
+ signal?.addEventListener("abort", onAbort, { once: true });
187
+ });
188
+ }
11
189
  /**
12
190
  * Creates an Antigravity OAuth plugin for a specific provider ID.
13
191
  */
@@ -15,9 +193,45 @@ export const createAntigravityPlugin = (providerId) => async ({ client }) => ({
15
193
  auth: {
16
194
  provider: providerId,
17
195
  loader: async (getAuth, provider) => {
196
+ // Track which account was used in the previous request for detecting switches
197
+ let previousAccountIndex = null;
18
198
  const auth = await getAuth();
199
+ // If OpenCode has no valid OAuth auth, clear any stale account storage
19
200
  if (!isOAuthAuth(auth)) {
20
- return null;
201
+ try {
202
+ await clearAccounts();
203
+ }
204
+ catch {
205
+ // ignore
206
+ }
207
+ return {};
208
+ }
209
+ // Validate that stored accounts are in sync with OpenCode's auth
210
+ // If OpenCode's refresh token doesn't match any stored account, clear stale storage
211
+ const authParts = parseRefreshParts(auth.refresh);
212
+ const storedAccounts = await loadAccounts();
213
+ if (storedAccounts && storedAccounts.accounts.length > 0 && authParts.refreshToken) {
214
+ const hasMatchingAccount = storedAccounts.accounts.some((acc) => acc.refreshToken === authParts.refreshToken);
215
+ if (!hasMatchingAccount) {
216
+ // OpenCode's auth doesn't match any stored account - storage is stale
217
+ // Clear it and let the user re-authenticate
218
+ console.warn("[opencode-antigravity-auth] Stored accounts don't match OpenCode's auth. Clearing stale storage.");
219
+ try {
220
+ await clearAccounts();
221
+ }
222
+ catch {
223
+ // ignore
224
+ }
225
+ }
226
+ }
227
+ const accountManager = await AccountManager.loadFromDisk(auth);
228
+ if (accountManager.getAccountCount() > 0) {
229
+ try {
230
+ await accountManager.saveToDisk();
231
+ }
232
+ catch (error) {
233
+ console.error("[opencode-antigravity-auth] Failed to persist initial account pool:", error);
234
+ }
21
235
  }
22
236
  if (provider.models) {
23
237
  for (const model of Object.values(provider.models)) {
@@ -39,89 +253,263 @@ export const createAntigravityPlugin = (providerId) => async ({ client }) => ({
39
253
  if (!isOAuthAuth(latestAuth)) {
40
254
  return fetch(input, init);
41
255
  }
42
- let authRecord = latestAuth;
43
- if (accessTokenExpired(authRecord)) {
44
- const refreshed = await refreshAccessToken(authRecord, client, providerId);
45
- if (!refreshed) {
46
- return fetch(input, init);
47
- }
48
- authRecord = refreshed;
256
+ if (accountManager.getAccountCount() === 0) {
257
+ throw new Error("No Antigravity accounts configured. Run `opencode auth login`.");
49
258
  }
50
- const accessToken = authRecord.access;
51
- if (!accessToken) {
52
- return fetch(input, init);
53
- }
54
- /**
55
- * Ensures we have a usable project context for the current auth snapshot.
56
- */
57
- async function resolveProjectContext() {
259
+ let lastFailure = null;
260
+ let lastError = null;
261
+ const abortSignal = init?.signal ?? undefined;
262
+ // Use while(true) loop to handle rate limits with backoff
263
+ // This ensures we wait and retry when all accounts are rate-limited
264
+ while (true) {
265
+ const accountCount = accountManager.getAccountCount();
266
+ if (accountCount === 0) {
267
+ throw new Error("No Antigravity accounts available. Run `opencode auth login`.");
268
+ }
269
+ const account = accountManager.pickNext();
270
+ if (!account) {
271
+ // All accounts are rate-limited - wait and retry
272
+ const waitMs = accountManager.getMinWaitTimeMs() || 60_000;
273
+ const waitSec = Math.max(1, Math.ceil(waitMs / 1000));
274
+ try {
275
+ await client.tui.showToast({
276
+ body: {
277
+ message: `All ${accountCount} account(s) rate-limited. Waiting ${waitSec}s...`,
278
+ variant: "warning",
279
+ },
280
+ });
281
+ }
282
+ catch {
283
+ // TUI may not be available
284
+ }
285
+ // Wait for the cooldown to expire
286
+ await sleep(waitMs, abortSignal);
287
+ continue;
288
+ }
289
+ // Show toast when switching to a different account
290
+ const isAccountSwitch = previousAccountIndex !== null && previousAccountIndex !== account.index;
291
+ if (isAccountSwitch || previousAccountIndex === null) {
292
+ const accountLabel = account.email || `Account ${account.index + 1}`;
293
+ try {
294
+ await client.tui.showToast({
295
+ body: {
296
+ message: `Using ${accountLabel}${accountCount > 1 ? ` (${account.index + 1}/${accountCount})` : ""}`,
297
+ variant: "info",
298
+ },
299
+ });
300
+ }
301
+ catch {
302
+ // TUI may not be available
303
+ }
304
+ }
305
+ previousAccountIndex = account.index;
58
306
  try {
59
- return await ensureProjectContext(authRecord, client, providerId);
307
+ await accountManager.saveToDisk();
60
308
  }
61
309
  catch (error) {
62
- throw error;
310
+ console.error("[opencode-antigravity-auth] Failed to persist rotation state:", error);
63
311
  }
64
- }
65
- const projectContext = await resolveProjectContext();
66
- // Endpoint fallback logic: try daily → autopush → prod
67
- let lastError = null;
68
- let lastResponse = null;
69
- for (let i = 0; i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length; i++) {
70
- const currentEndpoint = ANTIGRAVITY_ENDPOINT_FALLBACKS[i];
71
- try {
72
- const { request, init: transformedInit, streaming, requestedModel, effectiveModel, projectId: usedProjectId, endpoint: usedEndpoint, toolDebugMissing, toolDebugSummary, toolDebugPayload, } = prepareAntigravityRequest(input, init, accessToken, projectContext.effectiveProjectId, currentEndpoint);
73
- const originalUrl = toUrlString(input);
74
- const resolvedUrl = toUrlString(request);
75
- const debugContext = startAntigravityDebugRequest({
76
- originalUrl,
77
- resolvedUrl,
78
- method: transformedInit.method,
79
- headers: transformedInit.headers,
80
- body: transformedInit.body,
81
- streaming,
82
- projectId: projectContext.effectiveProjectId,
83
- });
84
- const response = await fetch(request, transformedInit);
85
- // Check if we should retry with next endpoint
86
- const shouldRetry = (response.status === 403 || // Forbidden
87
- response.status === 404 || // Not Found
88
- response.status === 429 || // Rate Limit
89
- response.status >= 500 // Server errors
90
- );
91
- if (shouldRetry && i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length - 1) {
92
- // Try next endpoint
93
- lastResponse = response;
312
+ let authRecord = accountManager.toAuthDetails(account);
313
+ if (accessTokenExpired(authRecord)) {
314
+ try {
315
+ const refreshed = await refreshAccessToken(authRecord, client, providerId);
316
+ if (!refreshed) {
317
+ lastError = new Error("Antigravity token refresh failed");
318
+ continue;
319
+ }
320
+ accountManager.updateFromAuth(account, refreshed);
321
+ authRecord = refreshed;
322
+ try {
323
+ await accountManager.saveToDisk();
324
+ }
325
+ catch (error) {
326
+ console.error("[opencode-antigravity-auth] Failed to persist refreshed auth:", error);
327
+ }
328
+ }
329
+ catch (error) {
330
+ if (error instanceof AntigravityTokenRefreshError && error.code === "invalid_grant") {
331
+ const removed = accountManager.removeAccount(account);
332
+ if (removed) {
333
+ console.warn("[opencode-antigravity-auth] Removed revoked account from pool. Reauthenticate it via `opencode auth login` to add it back.");
334
+ try {
335
+ await accountManager.saveToDisk();
336
+ }
337
+ catch (persistError) {
338
+ console.error("[opencode-antigravity-auth] Failed to persist revoked account removal:", persistError);
339
+ }
340
+ }
341
+ if (accountManager.getAccountCount() === 0) {
342
+ try {
343
+ await client.auth.set({
344
+ path: { id: providerId },
345
+ body: { type: "oauth", refresh: "", access: "", expires: 0 },
346
+ });
347
+ }
348
+ catch (storeError) {
349
+ console.error("Failed to clear stored Antigravity OAuth credentials:", storeError);
350
+ }
351
+ throw new Error("All Antigravity accounts have invalid refresh tokens. Run `opencode auth login` and reauthenticate.");
352
+ }
353
+ lastError = error;
354
+ continue;
355
+ }
356
+ lastError = error instanceof Error ? error : new Error(String(error));
94
357
  continue;
95
358
  }
96
- // Success or final attempt - return transformed response
97
- return transformAntigravityResponse(response, streaming, debugContext, requestedModel, usedProjectId, usedEndpoint, effectiveModel, toolDebugMissing, toolDebugSummary, toolDebugPayload);
359
+ }
360
+ const accessToken = authRecord.access;
361
+ if (!accessToken) {
362
+ lastError = new Error("Missing access token");
363
+ continue;
364
+ }
365
+ let projectContext;
366
+ try {
367
+ projectContext = await ensureProjectContext(authRecord);
98
368
  }
99
369
  catch (error) {
100
- // Network error or other exception
101
- if (i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length - 1) {
370
+ lastError = error instanceof Error ? error : new Error(String(error));
371
+ continue;
372
+ }
373
+ if (projectContext.auth !== authRecord) {
374
+ accountManager.updateFromAuth(account, projectContext.auth);
375
+ authRecord = projectContext.auth;
376
+ try {
377
+ await accountManager.saveToDisk();
378
+ }
379
+ catch (error) {
380
+ console.error("[opencode-antigravity-auth] Failed to persist project context:", error);
381
+ }
382
+ }
383
+ // Try endpoint fallbacks
384
+ let shouldSwitchAccount = false;
385
+ for (let i = 0; i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length; i++) {
386
+ const currentEndpoint = ANTIGRAVITY_ENDPOINT_FALLBACKS[i];
387
+ try {
388
+ const prepared = prepareAntigravityRequest(input, init, accessToken, projectContext.effectiveProjectId, currentEndpoint);
389
+ const originalUrl = toUrlString(input);
390
+ const resolvedUrl = toUrlString(prepared.request);
391
+ const debugContext = startAntigravityDebugRequest({
392
+ originalUrl,
393
+ resolvedUrl,
394
+ method: prepared.init.method,
395
+ headers: prepared.init.headers,
396
+ body: prepared.init.body,
397
+ streaming: prepared.streaming,
398
+ projectId: projectContext.effectiveProjectId,
399
+ });
400
+ const response = await fetch(prepared.request, prepared.init);
401
+ // Handle 429 rate limit
402
+ if (response.status === 429) {
403
+ const retryAfterMs = retryAfterMsFromResponse(response);
404
+ accountManager.markRateLimited(account, retryAfterMs);
405
+ try {
406
+ await accountManager.saveToDisk();
407
+ }
408
+ catch (error) {
409
+ console.error("[opencode-antigravity-auth] Failed to persist rate-limit state:", error);
410
+ }
411
+ const accountLabel = account.email || `Account ${account.index + 1}`;
412
+ if (accountManager.getAccountCount() > 1) {
413
+ // Multiple accounts - switch to next
414
+ try {
415
+ await client.tui.showToast({
416
+ body: {
417
+ message: `Rate limited on ${accountLabel}. Switching...`,
418
+ variant: "warning",
419
+ },
420
+ });
421
+ }
422
+ catch {
423
+ // TUI may not be available
424
+ }
425
+ lastFailure = {
426
+ response,
427
+ streaming: prepared.streaming,
428
+ debugContext,
429
+ requestedModel: prepared.requestedModel,
430
+ projectId: prepared.projectId,
431
+ endpoint: prepared.endpoint,
432
+ effectiveModel: prepared.effectiveModel,
433
+ toolDebugMissing: prepared.toolDebugMissing,
434
+ toolDebugSummary: prepared.toolDebugSummary,
435
+ toolDebugPayload: prepared.toolDebugPayload,
436
+ };
437
+ shouldSwitchAccount = true;
438
+ break;
439
+ }
440
+ else {
441
+ // Single account - wait and retry
442
+ const waitSec = Math.max(1, Math.ceil(retryAfterMs / 1000));
443
+ try {
444
+ await client.tui.showToast({
445
+ body: {
446
+ message: `Rate limited. Waiting ${waitSec}s...`,
447
+ variant: "warning",
448
+ },
449
+ });
450
+ }
451
+ catch {
452
+ // TUI may not be available
453
+ }
454
+ lastFailure = {
455
+ response,
456
+ streaming: prepared.streaming,
457
+ debugContext,
458
+ requestedModel: prepared.requestedModel,
459
+ projectId: prepared.projectId,
460
+ endpoint: prepared.endpoint,
461
+ effectiveModel: prepared.effectiveModel,
462
+ toolDebugMissing: prepared.toolDebugMissing,
463
+ toolDebugSummary: prepared.toolDebugSummary,
464
+ toolDebugPayload: prepared.toolDebugPayload,
465
+ };
466
+ // Wait and let the outer loop retry
467
+ await sleep(retryAfterMs, abortSignal);
468
+ shouldSwitchAccount = true;
469
+ break;
470
+ }
471
+ }
472
+ const shouldRetryEndpoint = (response.status === 403 ||
473
+ response.status === 404 ||
474
+ response.status >= 500);
475
+ if (shouldRetryEndpoint && i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length - 1) {
476
+ lastFailure = {
477
+ response,
478
+ streaming: prepared.streaming,
479
+ debugContext,
480
+ requestedModel: prepared.requestedModel,
481
+ projectId: prepared.projectId,
482
+ endpoint: prepared.endpoint,
483
+ effectiveModel: prepared.effectiveModel,
484
+ toolDebugMissing: prepared.toolDebugMissing,
485
+ toolDebugSummary: prepared.toolDebugSummary,
486
+ toolDebugPayload: prepared.toolDebugPayload,
487
+ };
488
+ continue;
489
+ }
490
+ // Success or non-retryable error - return the response
491
+ return transformAntigravityResponse(response, prepared.streaming, debugContext, prepared.requestedModel, prepared.projectId, prepared.endpoint, prepared.effectiveModel, prepared.toolDebugMissing, prepared.toolDebugSummary, prepared.toolDebugPayload);
492
+ }
493
+ catch (error) {
494
+ if (i < ANTIGRAVITY_ENDPOINT_FALLBACKS.length - 1) {
495
+ lastError = error instanceof Error ? error : new Error(String(error));
496
+ continue;
497
+ }
498
+ // All endpoints failed for this account - try next account
102
499
  lastError = error instanceof Error ? error : new Error(String(error));
103
- continue;
500
+ shouldSwitchAccount = true;
501
+ break;
104
502
  }
105
- // Final attempt failed, throw the error
106
- throw error;
107
503
  }
504
+ if (shouldSwitchAccount) {
505
+ continue;
506
+ }
507
+ // If we get here without returning, something went wrong
508
+ if (lastFailure) {
509
+ return transformAntigravityResponse(lastFailure.response, lastFailure.streaming, lastFailure.debugContext, lastFailure.requestedModel, lastFailure.projectId, lastFailure.endpoint, lastFailure.effectiveModel, lastFailure.toolDebugMissing, lastFailure.toolDebugSummary, lastFailure.toolDebugPayload);
510
+ }
511
+ throw lastError || new Error("All Antigravity accounts failed");
108
512
  }
109
- // If we get here, all endpoints failed
110
- if (lastResponse) {
111
- // Return the last response even if it was an error
112
- const { streaming, requestedModel, effectiveModel, projectId: usedProjectId, endpoint: usedEndpoint, toolDebugMissing, toolDebugSummary, toolDebugPayload, } = prepareAntigravityRequest(input, init, accessToken, projectContext.effectiveProjectId, ANTIGRAVITY_ENDPOINT_FALLBACKS[ANTIGRAVITY_ENDPOINT_FALLBACKS.length - 1]);
113
- const debugContext = startAntigravityDebugRequest({
114
- originalUrl: toUrlString(input),
115
- resolvedUrl: toUrlString(input),
116
- method: init?.method,
117
- headers: init?.headers,
118
- body: init?.body,
119
- streaming,
120
- projectId: projectContext.effectiveProjectId,
121
- });
122
- return transformAntigravityResponse(lastResponse, streaming, debugContext, requestedModel, usedProjectId, usedEndpoint, effectiveModel, toolDebugMissing, toolDebugSummary, toolDebugPayload);
123
- }
124
- throw lastError || new Error("All Antigravity endpoints failed");
125
513
  },
126
514
  };
127
515
  },
@@ -129,59 +517,201 @@ export const createAntigravityPlugin = (providerId) => async ({ client }) => ({
129
517
  {
130
518
  label: "OAuth with Google (Antigravity)",
131
519
  type: "oauth",
132
- authorize: async () => {
520
+ authorize: async (inputs) => {
133
521
  const isHeadless = !!(process.env.SSH_CONNECTION ||
134
522
  process.env.SSH_CLIENT ||
135
523
  process.env.SSH_TTY ||
136
524
  process.env.OPENCODE_HEADLESS);
525
+ // CLI flow (`opencode auth login`) passes an inputs object.
526
+ if (inputs) {
527
+ const accounts = [];
528
+ // Check for existing accounts and prompt user for login mode
529
+ let startFresh = true;
530
+ const existingStorage = await loadAccounts();
531
+ if (existingStorage && existingStorage.accounts.length > 0) {
532
+ const existingAccounts = existingStorage.accounts.map((acc, idx) => ({
533
+ email: acc.email,
534
+ index: idx,
535
+ }));
536
+ const loginMode = await promptLoginMode(existingAccounts);
537
+ startFresh = loginMode === "fresh";
538
+ if (startFresh) {
539
+ console.log("\nStarting fresh - existing accounts will be replaced.\n");
540
+ }
541
+ else {
542
+ console.log("\nAdding to existing accounts.\n");
543
+ }
544
+ }
545
+ while (accounts.length < MAX_OAUTH_ACCOUNTS) {
546
+ console.log(`\n=== Antigravity OAuth (Account ${accounts.length + 1}) ===`);
547
+ const projectId = await promptProjectId();
548
+ const result = await (async () => {
549
+ let listener = null;
550
+ if (!isHeadless) {
551
+ try {
552
+ listener = await startOAuthListener();
553
+ }
554
+ catch {
555
+ listener = null;
556
+ }
557
+ }
558
+ const authorization = await authorizeAntigravity(projectId);
559
+ const fallbackState = getStateFromAuthorizationUrl(authorization.url);
560
+ console.log("\nOAuth URL:\n" + authorization.url + "\n");
561
+ if (!isHeadless) {
562
+ await openBrowser(authorization.url);
563
+ }
564
+ if (listener) {
565
+ try {
566
+ const callbackUrl = await listener.waitForCallback();
567
+ const params = extractOAuthCallbackParams(callbackUrl);
568
+ if (!params) {
569
+ return { type: "failed", error: "Missing code or state in callback URL" };
570
+ }
571
+ return exchangeAntigravity(params.code, params.state);
572
+ }
573
+ catch (error) {
574
+ return {
575
+ type: "failed",
576
+ error: error instanceof Error ? error.message : "Unknown error",
577
+ };
578
+ }
579
+ finally {
580
+ try {
581
+ await listener.close();
582
+ }
583
+ catch {
584
+ // ignore
585
+ }
586
+ }
587
+ }
588
+ console.log("1. Open the URL below in your browser and complete Google sign-in.");
589
+ console.log("2. After approving, copy the full redirected localhost URL from the address bar.");
590
+ console.log("3. Paste it back here.");
591
+ const callbackInput = await promptOAuthCallbackValue("Paste the redirect URL (or just the code) here: ");
592
+ const params = parseOAuthCallbackInput(callbackInput, fallbackState);
593
+ if ("error" in params) {
594
+ return { type: "failed", error: params.error };
595
+ }
596
+ return exchangeAntigravity(params.code, params.state);
597
+ })();
598
+ if (result.type === "failed") {
599
+ if (accounts.length === 0) {
600
+ return {
601
+ url: "",
602
+ instructions: `Authentication failed: ${result.error}`,
603
+ method: "auto",
604
+ callback: async () => result,
605
+ };
606
+ }
607
+ console.warn(`[opencode-antigravity-auth] Skipping failed account ${accounts.length + 1}: ${result.error}`);
608
+ break;
609
+ }
610
+ accounts.push(result);
611
+ // Show toast for successful account authentication
612
+ try {
613
+ await client.tui.showToast({
614
+ body: {
615
+ message: `Account ${accounts.length} authenticated${result.email ? ` (${result.email})` : ""}`,
616
+ variant: "success",
617
+ },
618
+ });
619
+ }
620
+ catch {
621
+ // TUI may not be available in CLI mode
622
+ }
623
+ try {
624
+ // Use startFresh only on first account, subsequent accounts always append
625
+ const isFirstAccount = accounts.length === 1;
626
+ await persistAccountPool([result], isFirstAccount && startFresh);
627
+ }
628
+ catch {
629
+ // ignore
630
+ }
631
+ if (accounts.length >= MAX_OAUTH_ACCOUNTS) {
632
+ break;
633
+ }
634
+ const addAnother = await promptAddAnotherAccount(accounts.length);
635
+ if (!addAnother) {
636
+ break;
637
+ }
638
+ }
639
+ const primary = accounts[0];
640
+ if (!primary) {
641
+ return {
642
+ url: "",
643
+ instructions: "Authentication cancelled",
644
+ method: "auto",
645
+ callback: async () => ({ type: "failed", error: "Authentication cancelled" }),
646
+ };
647
+ }
648
+ return {
649
+ url: "",
650
+ instructions: `Multi-account setup complete (${accounts.length} account(s)).`,
651
+ method: "auto",
652
+ callback: async () => primary,
653
+ };
654
+ }
655
+ // TUI flow (`/connect`) does not support per-account prompts.
656
+ // Default to adding new accounts (non-destructive).
657
+ // Users can run `opencode auth logout` first if they want a fresh start.
658
+ const projectId = "";
659
+ // Check existing accounts count for toast message
660
+ const existingStorage = await loadAccounts();
661
+ const existingCount = existingStorage?.accounts.length ?? 0;
137
662
  let listener = null;
138
663
  if (!isHeadless) {
139
664
  try {
140
665
  listener = await startOAuthListener();
141
666
  }
142
- catch (error) {
143
- console.log("\nWarning: Couldn't start the local callback listener. Falling back to manual copy/paste.");
667
+ catch {
668
+ listener = null;
144
669
  }
145
670
  }
146
- const authorization = await authorizeAntigravity("");
147
- // Try to open the browser automatically
671
+ const authorization = await authorizeAntigravity(projectId);
672
+ const fallbackState = getStateFromAuthorizationUrl(authorization.url);
148
673
  if (!isHeadless) {
149
- try {
150
- if (process.platform === "darwin") {
151
- exec(`open "${authorization.url}"`);
152
- }
153
- else if (process.platform === "win32") {
154
- exec(`start "${authorization.url}"`);
155
- }
156
- else {
157
- exec(`xdg-open "${authorization.url}"`);
158
- }
159
- }
160
- catch (e) {
161
- console.log("Could not open browser automatically. Please Copy/Paste the URL below.");
162
- }
674
+ await openBrowser(authorization.url);
163
675
  }
164
676
  if (listener) {
165
- const { host } = new URL(ANTIGRAVITY_REDIRECT_URI);
166
677
  return {
167
678
  url: authorization.url,
168
- instructions: "Complete the sign-in flow in your browser. We'll automatically detect the redirect back to localhost.",
679
+ instructions: "Complete sign-in in your browser. We'll automatically detect the redirect back to localhost.",
169
680
  method: "auto",
170
681
  callback: async () => {
171
682
  try {
172
- // We know listener is not null here because we checked 'if (listener)'
173
- // But TS might need a check or non-null assertion if not inferable.
174
- // Since we are in the if (listener) block, it is safe.
175
683
  const callbackUrl = await listener.waitForCallback();
176
- const code = callbackUrl.searchParams.get("code");
177
- const state = callbackUrl.searchParams.get("state");
178
- if (!code || !state) {
179
- return {
180
- type: "failed",
181
- error: "Missing code or state in callback URL",
182
- };
684
+ const params = extractOAuthCallbackParams(callbackUrl);
685
+ if (!params) {
686
+ return { type: "failed", error: "Missing code or state in callback URL" };
687
+ }
688
+ const result = await exchangeAntigravity(params.code, params.state);
689
+ if (result.type === "success") {
690
+ try {
691
+ // TUI flow adds to existing accounts (non-destructive)
692
+ await persistAccountPool([result], false);
693
+ }
694
+ catch {
695
+ // ignore
696
+ }
697
+ // Show appropriate toast message
698
+ const newTotal = existingCount + 1;
699
+ const toastMessage = existingCount > 0
700
+ ? `Added account${result.email ? ` (${result.email})` : ""} - ${newTotal} total`
701
+ : `Authenticated${result.email ? ` (${result.email})` : ""}`;
702
+ try {
703
+ await client.tui.showToast({
704
+ body: {
705
+ message: toastMessage,
706
+ variant: "success",
707
+ },
708
+ });
709
+ }
710
+ catch {
711
+ // TUI may not be available
712
+ }
183
713
  }
184
- return await exchangeAntigravity(code, state);
714
+ return result;
185
715
  }
186
716
  catch (error) {
187
717
  return {
@@ -191,9 +721,10 @@ export const createAntigravityPlugin = (providerId) => async ({ client }) => ({
191
721
  }
192
722
  finally {
193
723
  try {
194
- await listener?.close();
724
+ await listener.close();
195
725
  }
196
726
  catch {
727
+ // ignore
197
728
  }
198
729
  }
199
730
  },
@@ -201,33 +732,45 @@ export const createAntigravityPlugin = (providerId) => async ({ client }) => ({
201
732
  }
202
733
  return {
203
734
  url: authorization.url,
204
- instructions: "Paste the full redirected URL (e.g., http://localhost:8085/oauth2callback?code=...): ",
735
+ instructions: "Visit the URL above, complete OAuth, then paste either the full redirect URL or the authorization code.",
205
736
  method: "code",
206
- callback: async (callbackUrl) => {
207
- try {
208
- const url = new URL(callbackUrl);
209
- const code = url.searchParams.get("code");
210
- const state = url.searchParams.get("state");
211
- if (!code || !state) {
212
- return {
213
- type: "failed",
214
- error: "Missing code or state in callback URL",
215
- };
216
- }
217
- return exchangeAntigravity(code, state);
737
+ callback: async (codeInput) => {
738
+ const params = parseOAuthCallbackInput(codeInput, fallbackState);
739
+ if ("error" in params) {
740
+ return { type: "failed", error: params.error };
218
741
  }
219
- catch (error) {
220
- return {
221
- type: "failed",
222
- error: error instanceof Error ? error.message : "Unknown error",
223
- };
742
+ const result = await exchangeAntigravity(params.code, params.state);
743
+ if (result.type === "success") {
744
+ try {
745
+ // TUI flow adds to existing accounts (non-destructive)
746
+ await persistAccountPool([result], false);
747
+ }
748
+ catch {
749
+ // ignore
750
+ }
751
+ // Show appropriate toast message
752
+ const newTotal = existingCount + 1;
753
+ const toastMessage = existingCount > 0
754
+ ? `Added account${result.email ? ` (${result.email})` : ""} - ${newTotal} total`
755
+ : `Authenticated${result.email ? ` (${result.email})` : ""}`;
756
+ try {
757
+ await client.tui.showToast({
758
+ body: {
759
+ message: toastMessage,
760
+ variant: "success",
761
+ },
762
+ });
763
+ }
764
+ catch {
765
+ // TUI may not be available
766
+ }
224
767
  }
768
+ return result;
225
769
  },
226
770
  };
227
771
  },
228
772
  },
229
773
  {
230
- provider: providerId,
231
774
  label: "Manually enter API Key",
232
775
  type: "api",
233
776
  },