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.
- package/README.md +103 -14
- package/dist/src/plugin/accounts.d.ts +37 -0
- package/dist/src/plugin/accounts.d.ts.map +1 -0
- package/dist/src/plugin/accounts.js +197 -0
- package/dist/src/plugin/accounts.js.map +1 -0
- package/dist/src/plugin/accounts.test.d.ts +2 -0
- package/dist/src/plugin/accounts.test.d.ts.map +1 -0
- package/dist/src/plugin/accounts.test.js +139 -0
- package/dist/src/plugin/accounts.test.js.map +1 -0
- package/dist/src/plugin/cli.d.ts +14 -0
- package/dist/src/plugin/cli.d.ts.map +1 -1
- package/dist/src/plugin/cli.js +43 -0
- package/dist/src/plugin/cli.js.map +1 -1
- package/dist/src/plugin/project.d.ts +2 -2
- package/dist/src/plugin/project.d.ts.map +1 -1
- package/dist/src/plugin/project.js +1 -5
- package/dist/src/plugin/project.js.map +1 -1
- package/dist/src/plugin/storage.d.ts +25 -0
- package/dist/src/plugin/storage.d.ts.map +1 -0
- package/dist/src/plugin/storage.js +59 -0
- package/dist/src/plugin/storage.js.map +1 -0
- package/dist/src/plugin/token.d.ts +13 -0
- package/dist/src/plugin/token.d.ts.map +1 -1
- package/dist/src/plugin/token.js +29 -30
- package/dist/src/plugin/token.js.map +1 -1
- package/dist/src/plugin/token.test.js +19 -11
- package/dist/src/plugin/token.test.js.map +1 -1
- package/dist/src/plugin/types.d.ts +36 -20
- package/dist/src/plugin/types.d.ts.map +1 -1
- package/dist/src/plugin.d.ts.map +1 -1
- package/dist/src/plugin.js +670 -127
- package/dist/src/plugin.js.map +1 -1
- package/package.json +1 -1
package/dist/src/plugin.js
CHANGED
|
@@ -1,13 +1,191 @@
|
|
|
1
1
|
import { exec } from "node:child_process";
|
|
2
|
-
import { ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_PROVIDER_ID
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
307
|
+
await accountManager.saveToDisk();
|
|
60
308
|
}
|
|
61
309
|
catch (error) {
|
|
62
|
-
|
|
310
|
+
console.error("[opencode-antigravity-auth] Failed to persist rotation state:", error);
|
|
63
311
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
|
143
|
-
|
|
667
|
+
catch {
|
|
668
|
+
listener = null;
|
|
144
669
|
}
|
|
145
670
|
}
|
|
146
|
-
const authorization = await authorizeAntigravity(
|
|
147
|
-
|
|
671
|
+
const authorization = await authorizeAntigravity(projectId);
|
|
672
|
+
const fallbackState = getStateFromAuthorizationUrl(authorization.url);
|
|
148
673
|
if (!isHeadless) {
|
|
149
|
-
|
|
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
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
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
|
|
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: "
|
|
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 (
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
},
|