@foxden-app/foxclaw 0.7.2 → 0.10.0
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/.env.example +14 -0
- package/CHANGELOG.md +129 -0
- package/README.md +22 -2
- package/README_EN.md +22 -2
- package/dist/antigravity/adapter.d.ts +17 -0
- package/dist/antigravity/adapter.js +256 -0
- package/dist/antigravity/auth.d.ts +121 -0
- package/dist/antigravity/auth.js +896 -0
- package/dist/antigravity/client.d.ts +32 -0
- package/dist/antigravity/client.js +238 -0
- package/dist/antigravity/controller.d.ts +96 -0
- package/dist/antigravity/controller.js +1922 -0
- package/dist/antigravity/conversations.d.ts +29 -0
- package/dist/antigravity/conversations.js +289 -0
- package/dist/antigravity/events.d.ts +101 -0
- package/dist/antigravity/events.js +114 -0
- package/dist/antigravity/runtime.d.ts +47 -0
- package/dist/antigravity/runtime.js +82 -0
- package/dist/auth/cross_node_sync.js +2 -2
- package/dist/channels/telegram/telegram_channel_adapter.d.ts +10 -3
- package/dist/channels/telegram/telegram_channel_adapter.js +2 -1
- package/dist/channels/weixin/weixin_channel_adapter.d.ts +5 -1
- package/dist/codex_app/adapter.d.ts +20 -0
- package/dist/codex_app/adapter.js +213 -0
- package/dist/codex_app/client.d.ts +2 -1
- package/dist/codex_app/client.js +47 -4
- package/dist/codex_app/force_takeover.d.ts +12 -0
- package/dist/codex_app/force_takeover.js +27 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +42 -0
- package/dist/controller/controller.d.ts +15 -5
- package/dist/controller/controller.js +252 -20
- package/dist/core/attachments.d.ts +11 -0
- package/dist/core/attachments.js +56 -0
- package/dist/core/engine_spi.d.ts +76 -0
- package/dist/core/engine_spi.js +1 -0
- package/dist/core/orchestrator.d.ts +101 -0
- package/dist/core/orchestrator.js +1404 -0
- package/dist/core/stream_preview.d.ts +29 -0
- package/dist/core/stream_preview.js +146 -0
- package/dist/core/turn_queue.d.ts +19 -0
- package/dist/core/turn_queue.js +41 -0
- package/dist/i18n.d.ts +36 -6
- package/dist/i18n.js +81 -6
- package/dist/main.js +190 -20
- package/dist/opencode/adapter.d.ts +11 -0
- package/dist/opencode/adapter.js +168 -0
- package/dist/store/database.d.ts +41 -0
- package/dist/store/database.js +242 -43
- package/dist/store/token_usage.d.ts +23 -0
- package/dist/store/token_usage.js +61 -0
- package/dist/telegram/api.js +34 -1
- package/dist/telegram/bot_home.d.ts +3 -0
- package/dist/telegram/bot_home.js +107 -0
- package/dist/telegram/gateway.d.ts +5 -0
- package/dist/telegram/gateway.js +21 -0
- package/dist/types.d.ts +1 -0
- package/dist/update.d.ts +6 -0
- package/dist/update.js +78 -6
- package/dist/voice/target.js +2 -1
- package/dist/voice/tts.js +4 -2
- package/docs/devlogs//346/212/200/346/234/257/351/200/232/350/256/257/02./347/273/237/344/270/200/351/200/232/351/201/223/350/260/203/345/272/246/345/231/250/344/270/216/345/217/257/346/217/222/346/213/224/345/274/225/346/223/216SPI/357/274/232FoxClaw/344/273/216/345/215/225/344/275/223/350/265/260/345/220/221/345/244/232Agent/345/272/225/345/272/247.md +192 -0
- package/docs/devlogs//347/254/254/344/270/200/344/272/272/347/247/260/347/211/210/02./347/257/207/344/272/214_/345/275/223/344/270/200/344/270/252/346/241/245/346/216/245/345/231/250/345/274/200/345/247/213/345/255/246/344/274/232/345/220/254/346/207/202/346/211/200/346/234/211Agent.md +45 -0
- package/docs/user-manual.md +11 -1
- package/docs/zh/2026-09-05-bot-home-migration.md +26 -0
- package/docs/zh/2026-09-05-reliability-review.md +53 -0
- package/docs/zh/troubleshooting.md +12 -0
- package/docs/zh/user-manual.md +13 -1
- package/package.json +2 -2
- package/scripts/force-takeover.py +118 -0
- package/scripts/force-takeover.test.py +122 -0
|
@@ -0,0 +1,896 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fsSync from 'node:fs';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
export const GOOGLE_OAUTH_CLIENT_ID = process.env.GOOGLE_OAUTH_CLIENT_ID ||
|
|
7
|
+
['1071006060591', 'tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com'].join('-');
|
|
8
|
+
export const GOOGLE_OAUTH_CLIENT_SECRET = process.env.GOOGLE_OAUTH_CLIENT_SECRET ||
|
|
9
|
+
['GOCSPX', 'K58FWR486LdLJ1mLB8sXC4z6qDAf'].join('-');
|
|
10
|
+
export const GOOGLE_TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
|
|
11
|
+
export const GOOGLE_QUOTA_ENDPOINT = 'https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary';
|
|
12
|
+
export function parseExpiryToMs(val) {
|
|
13
|
+
if (typeof val === 'number') {
|
|
14
|
+
return val > 1e11 ? val : val * 1000;
|
|
15
|
+
}
|
|
16
|
+
if (typeof val === 'string') {
|
|
17
|
+
const t = new Date(val).getTime();
|
|
18
|
+
if (Number.isFinite(t))
|
|
19
|
+
return t;
|
|
20
|
+
const num = Number(val);
|
|
21
|
+
if (Number.isFinite(num)) {
|
|
22
|
+
return num > 1e11 ? num : num * 1000;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
export function parseEmailFromIdToken(idToken) {
|
|
28
|
+
if (!idToken || typeof idToken !== 'string')
|
|
29
|
+
return null;
|
|
30
|
+
const parts = idToken.split('.');
|
|
31
|
+
if (parts.length < 2)
|
|
32
|
+
return null;
|
|
33
|
+
try {
|
|
34
|
+
const payload = parts[1];
|
|
35
|
+
const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4);
|
|
36
|
+
const decoded = Buffer.from(padded, 'base64url').toString('utf8');
|
|
37
|
+
const json = JSON.parse(decoded);
|
|
38
|
+
return typeof json.email === 'string' ? json.email : null;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function formatQuotaResetTime(isoString) {
|
|
45
|
+
if (!isoString)
|
|
46
|
+
return '';
|
|
47
|
+
try {
|
|
48
|
+
const d = new Date(isoString);
|
|
49
|
+
if (Number.isNaN(d.getTime()))
|
|
50
|
+
return '';
|
|
51
|
+
const now = new Date();
|
|
52
|
+
const isSameDay = d.getFullYear() === now.getFullYear() &&
|
|
53
|
+
d.getMonth() === now.getMonth() &&
|
|
54
|
+
d.getDate() === now.getDate();
|
|
55
|
+
const timeStr = d.toLocaleTimeString('zh-CN', {
|
|
56
|
+
timeZone: 'Asia/Shanghai',
|
|
57
|
+
hour: '2-digit',
|
|
58
|
+
minute: '2-digit',
|
|
59
|
+
hour12: false,
|
|
60
|
+
});
|
|
61
|
+
if (isSameDay) {
|
|
62
|
+
return timeStr;
|
|
63
|
+
}
|
|
64
|
+
const month = d.getMonth() + 1;
|
|
65
|
+
const day = d.getDate();
|
|
66
|
+
return `${month}/${day} ${timeStr}`;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return '';
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export class AntigravityAuthManager {
|
|
73
|
+
authDir;
|
|
74
|
+
activeTokenPath;
|
|
75
|
+
pausedAccountsPath;
|
|
76
|
+
logger;
|
|
77
|
+
fetchFn;
|
|
78
|
+
cooldowns = new Map();
|
|
79
|
+
pendingLogins = new Map();
|
|
80
|
+
quotaCache = new Map();
|
|
81
|
+
pausedAccounts = new Set();
|
|
82
|
+
keepAliveTimer;
|
|
83
|
+
constructor(authDir, logger, fetchFn = fetch) {
|
|
84
|
+
this.authDir = authDir || path.join(os.homedir(), '.gemini', 'antigravity-cli');
|
|
85
|
+
this.activeTokenPath = path.join(this.authDir, 'antigravity-oauth-token');
|
|
86
|
+
this.pausedAccountsPath = path.join(this.authDir, 'paused-accounts.json');
|
|
87
|
+
this.logger = logger;
|
|
88
|
+
this.fetchFn = fetchFn;
|
|
89
|
+
this.loadPausedAccounts();
|
|
90
|
+
}
|
|
91
|
+
startBrowserLogin(scopeId) {
|
|
92
|
+
const codeVerifier = crypto.randomBytes(32).toString('base64url');
|
|
93
|
+
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
|
|
94
|
+
const state = crypto.randomBytes(16).toString('base64url');
|
|
95
|
+
this.pendingLogins.set(scopeId, {
|
|
96
|
+
scopeId,
|
|
97
|
+
codeVerifier,
|
|
98
|
+
state,
|
|
99
|
+
createdAt: Date.now(),
|
|
100
|
+
});
|
|
101
|
+
const params = new URLSearchParams({
|
|
102
|
+
access_type: 'offline',
|
|
103
|
+
client_id: GOOGLE_OAUTH_CLIENT_ID,
|
|
104
|
+
code_challenge: codeChallenge,
|
|
105
|
+
code_challenge_method: 'S256',
|
|
106
|
+
prompt: 'consent',
|
|
107
|
+
redirect_uri: 'https://antigravity.google/oauth-callback',
|
|
108
|
+
response_type: 'code',
|
|
109
|
+
scope: [
|
|
110
|
+
'https://www.googleapis.com/auth/cloud-platform',
|
|
111
|
+
'https://www.googleapis.com/auth/userinfo.email',
|
|
112
|
+
'https://www.googleapis.com/auth/userinfo.profile',
|
|
113
|
+
'https://www.googleapis.com/auth/cclog',
|
|
114
|
+
'https://www.googleapis.com/auth/experimentsandconfigs',
|
|
115
|
+
'https://www.googleapis.com/auth/aicode',
|
|
116
|
+
'openid',
|
|
117
|
+
].join(' '),
|
|
118
|
+
state,
|
|
119
|
+
});
|
|
120
|
+
const authUrl = `https://accounts.google.com/o/oauth2/auth?${params.toString()}`;
|
|
121
|
+
return { authUrl, state };
|
|
122
|
+
}
|
|
123
|
+
hasPendingLogin(scopeId) {
|
|
124
|
+
const session = this.pendingLogins.get(scopeId);
|
|
125
|
+
if (!session)
|
|
126
|
+
return false;
|
|
127
|
+
if (Date.now() - session.createdAt > 10 * 60 * 1000) {
|
|
128
|
+
this.pendingLogins.delete(scopeId);
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
cancelBrowserLogin(scopeId) {
|
|
134
|
+
return this.pendingLogins.delete(scopeId);
|
|
135
|
+
}
|
|
136
|
+
async completeBrowserLogin(scopeId, rawInput) {
|
|
137
|
+
const session = this.pendingLogins.get(scopeId);
|
|
138
|
+
if (!session || Date.now() - session.createdAt > 10 * 60 * 1000) {
|
|
139
|
+
this.pendingLogins.delete(scopeId);
|
|
140
|
+
return {
|
|
141
|
+
success: false,
|
|
142
|
+
error: 'Login session expired or not found. Please run /login again.',
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
let code = rawInput.trim();
|
|
146
|
+
try {
|
|
147
|
+
if (code.includes('code=')) {
|
|
148
|
+
const match = code.match(/code=([^&\s]+)/);
|
|
149
|
+
if (match?.[1]) {
|
|
150
|
+
code = decodeURIComponent(match[1]);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
/* ignore */
|
|
156
|
+
}
|
|
157
|
+
if (!code) {
|
|
158
|
+
return {
|
|
159
|
+
success: false,
|
|
160
|
+
error: 'Could not extract authorization code from input.',
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const tokenParams = new URLSearchParams({
|
|
165
|
+
client_id: GOOGLE_OAUTH_CLIENT_ID,
|
|
166
|
+
client_secret: GOOGLE_OAUTH_CLIENT_SECRET,
|
|
167
|
+
code,
|
|
168
|
+
code_verifier: session.codeVerifier,
|
|
169
|
+
grant_type: 'authorization_code',
|
|
170
|
+
redirect_uri: 'https://antigravity.google/oauth-callback',
|
|
171
|
+
});
|
|
172
|
+
const res = await this.fetchFn(GOOGLE_TOKEN_ENDPOINT, {
|
|
173
|
+
method: 'POST',
|
|
174
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
175
|
+
body: tokenParams.toString(),
|
|
176
|
+
});
|
|
177
|
+
if (!res.ok) {
|
|
178
|
+
const errBody = await res.text();
|
|
179
|
+
this.logger?.warn('antigravity.auth.code_exchange_failed', { error: errBody });
|
|
180
|
+
return {
|
|
181
|
+
success: false,
|
|
182
|
+
error: `Google rejected authorization code (${res.status}): ${errBody}`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
const data = (await res.json());
|
|
186
|
+
if (!data.refresh_token) {
|
|
187
|
+
return {
|
|
188
|
+
success: false,
|
|
189
|
+
error: 'Google did not return a refresh_token. Please ensure you granted consent.',
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const email = parseEmailFromIdToken(data.id_token);
|
|
193
|
+
const baseName = email ? email.replace(/[@.]/g, '_') : `acc_${Date.now().toString(36)}`;
|
|
194
|
+
const cleanName = baseName.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
195
|
+
const targetFileName = `antigravity-oauth-token_${cleanName}`;
|
|
196
|
+
const destPath = path.join(this.authDir, targetFileName);
|
|
197
|
+
const standardData = {
|
|
198
|
+
auth_method: 'consumer',
|
|
199
|
+
id_token: data.id_token ?? null,
|
|
200
|
+
token: {
|
|
201
|
+
access_token: data.access_token,
|
|
202
|
+
token_type: data.token_type || 'Bearer',
|
|
203
|
+
refresh_token: data.refresh_token,
|
|
204
|
+
expiry: new Date(Date.now() + (data.expires_in ?? 3600) * 1000).toISOString(),
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
await this.atomicWriteJson(destPath, standardData);
|
|
208
|
+
const active = await this.readActiveTokenContent();
|
|
209
|
+
if (!active) {
|
|
210
|
+
await this.atomicWriteJson(this.activeTokenPath, standardData);
|
|
211
|
+
}
|
|
212
|
+
this.pendingLogins.delete(scopeId);
|
|
213
|
+
const candidates = await this.listCandidates();
|
|
214
|
+
const createdAcc = candidates.find((a) => a.name === cleanName);
|
|
215
|
+
return {
|
|
216
|
+
success: true,
|
|
217
|
+
account: createdAcc,
|
|
218
|
+
createdFileName: targetFileName,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
return {
|
|
223
|
+
success: false,
|
|
224
|
+
error: err instanceof Error ? err.message : String(err),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
markCooldown(nameOrEmail, durationMs = 30 * 60 * 1000) {
|
|
229
|
+
const until = Date.now() + durationMs;
|
|
230
|
+
this.cooldowns.set(nameOrEmail.toLowerCase(), until);
|
|
231
|
+
this.logger?.info('antigravity.auth.cooldown_marked', { nameOrEmail, durationMs });
|
|
232
|
+
}
|
|
233
|
+
clearCooldown(nameOrEmail) {
|
|
234
|
+
this.cooldowns.delete(nameOrEmail.toLowerCase());
|
|
235
|
+
}
|
|
236
|
+
isCooldown(nameOrEmail) {
|
|
237
|
+
const until = this.cooldowns.get(nameOrEmail.toLowerCase());
|
|
238
|
+
return until !== undefined && until > Date.now();
|
|
239
|
+
}
|
|
240
|
+
getCooldownRemainingSec(nameOrEmail) {
|
|
241
|
+
const until = this.cooldowns.get(nameOrEmail.toLowerCase());
|
|
242
|
+
if (!until || until <= Date.now())
|
|
243
|
+
return 0;
|
|
244
|
+
return Math.ceil((until - Date.now()) / 1000);
|
|
245
|
+
}
|
|
246
|
+
loadPausedAccounts() {
|
|
247
|
+
try {
|
|
248
|
+
if (fsSync.existsSync(this.pausedAccountsPath)) {
|
|
249
|
+
const raw = fsSync.readFileSync(this.pausedAccountsPath, 'utf8');
|
|
250
|
+
const data = JSON.parse(raw);
|
|
251
|
+
if (Array.isArray(data)) {
|
|
252
|
+
this.pausedAccounts.clear();
|
|
253
|
+
for (const item of data) {
|
|
254
|
+
if (typeof item === 'string')
|
|
255
|
+
this.pausedAccounts.add(item.toLowerCase());
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
this.logger?.debug('antigravity.auth.load_paused_failed', { error: String(err) });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
savePausedAccounts() {
|
|
265
|
+
try {
|
|
266
|
+
fsSync.writeFileSync(this.pausedAccountsPath, JSON.stringify([...this.pausedAccounts], null, 2), 'utf8');
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
this.logger?.warn('antigravity.auth.save_paused_failed', { error: String(err) });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
isAccountPaused(nameOrEmail) {
|
|
273
|
+
return this.pausedAccounts.has(nameOrEmail.toLowerCase());
|
|
274
|
+
}
|
|
275
|
+
togglePauseAccount(targetNameOrEmail) {
|
|
276
|
+
const key = targetNameOrEmail.toLowerCase();
|
|
277
|
+
if (this.pausedAccounts.has(key)) {
|
|
278
|
+
this.pausedAccounts.delete(key);
|
|
279
|
+
this.savePausedAccounts();
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
this.pausedAccounts.add(key);
|
|
284
|
+
this.savePausedAccounts();
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
pauseAccount(targetNameOrEmail) {
|
|
289
|
+
this.pausedAccounts.add(targetNameOrEmail.toLowerCase());
|
|
290
|
+
this.savePausedAccounts();
|
|
291
|
+
}
|
|
292
|
+
resumeAccount(targetNameOrEmail) {
|
|
293
|
+
this.pausedAccounts.delete(targetNameOrEmail.toLowerCase());
|
|
294
|
+
this.savePausedAccounts();
|
|
295
|
+
}
|
|
296
|
+
async diagnoseAndRepairAccount(targetNameOrEmail) {
|
|
297
|
+
const accounts = await this.listCandidates(false);
|
|
298
|
+
const key = targetNameOrEmail.toLowerCase();
|
|
299
|
+
const target = accounts.find((a) => a.name.toLowerCase() === key ||
|
|
300
|
+
a.name === `antigravity-oauth-token_${targetNameOrEmail}` ||
|
|
301
|
+
(a.email && a.email.toLowerCase() === key) ||
|
|
302
|
+
(targetNameOrEmail === 'active' && a.isActive));
|
|
303
|
+
if (!target) {
|
|
304
|
+
return {
|
|
305
|
+
ok: false,
|
|
306
|
+
accountName: targetNameOrEmail,
|
|
307
|
+
email: null,
|
|
308
|
+
message: `Account '${targetNameOrEmail}' not found.`,
|
|
309
|
+
refreshed: false,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
if (!target.hasRefreshToken) {
|
|
313
|
+
return {
|
|
314
|
+
ok: false,
|
|
315
|
+
accountName: target.name,
|
|
316
|
+
email: target.email,
|
|
317
|
+
message: 'No refresh token available. Re-authentication via device login required.',
|
|
318
|
+
refreshed: false,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
const refreshRes = await this.refreshTokenForAccount(target.name);
|
|
322
|
+
if (!refreshRes.success) {
|
|
323
|
+
return {
|
|
324
|
+
ok: false,
|
|
325
|
+
accountName: target.name,
|
|
326
|
+
email: target.email,
|
|
327
|
+
message: `Token refresh failed: ${refreshRes.error || 'Unknown error'}`,
|
|
328
|
+
refreshed: false,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
this.clearCooldown(target.name);
|
|
332
|
+
if (target.email)
|
|
333
|
+
this.clearCooldown(target.email);
|
|
334
|
+
this.resumeAccount(target.name);
|
|
335
|
+
if (target.email)
|
|
336
|
+
this.resumeAccount(target.email);
|
|
337
|
+
try {
|
|
338
|
+
await this.fetchQuotaForAccount(target.name, true);
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
/* ignore */
|
|
342
|
+
}
|
|
343
|
+
return {
|
|
344
|
+
ok: true,
|
|
345
|
+
accountName: target.name,
|
|
346
|
+
email: target.email,
|
|
347
|
+
message: 'Token refreshed and quota synchronized successfully.',
|
|
348
|
+
refreshed: true,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
async diagnoseAndRepairAll() {
|
|
352
|
+
const accounts = await this.listCandidates(false);
|
|
353
|
+
let healthy = 0;
|
|
354
|
+
let repaired = 0;
|
|
355
|
+
let failed = 0;
|
|
356
|
+
const summary = [];
|
|
357
|
+
for (const acc of accounts) {
|
|
358
|
+
const res = await this.diagnoseAndRepairAccount(acc.name);
|
|
359
|
+
if (res.ok) {
|
|
360
|
+
if (res.refreshed)
|
|
361
|
+
repaired++;
|
|
362
|
+
else
|
|
363
|
+
healthy++;
|
|
364
|
+
summary.push(`✅ \`${res.email || res.accountName}\`: 正常/已刷新`);
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
failed++;
|
|
368
|
+
summary.push(`❌ \`${res.email || res.accountName}\`: ${res.message}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return {
|
|
372
|
+
total: accounts.length,
|
|
373
|
+
healthy,
|
|
374
|
+
repaired,
|
|
375
|
+
failed,
|
|
376
|
+
summary,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
getCachedQuota(name, email) {
|
|
380
|
+
const qByName = this.quotaCache.get(name.toLowerCase());
|
|
381
|
+
if (qByName)
|
|
382
|
+
return qByName;
|
|
383
|
+
if (email) {
|
|
384
|
+
const qByEmail = this.quotaCache.get(email.toLowerCase());
|
|
385
|
+
if (qByEmail)
|
|
386
|
+
return qByEmail;
|
|
387
|
+
}
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
async fetchQuotaForAccount(targetNameOrEmail, force = false) {
|
|
391
|
+
const key = targetNameOrEmail.toLowerCase();
|
|
392
|
+
const cached = this.quotaCache.get(key);
|
|
393
|
+
if (!force && cached && Date.now() - cached.updatedAt < 5 * 60 * 1000) {
|
|
394
|
+
return cached;
|
|
395
|
+
}
|
|
396
|
+
const accounts = await this.listCandidates(false);
|
|
397
|
+
const target = accounts.find((a) => a.name.toLowerCase() === key ||
|
|
398
|
+
a.name === `antigravity-oauth-token_${targetNameOrEmail}` ||
|
|
399
|
+
(a.email && a.email.toLowerCase() === key) ||
|
|
400
|
+
(targetNameOrEmail === 'active' && a.isActive));
|
|
401
|
+
if (!target)
|
|
402
|
+
return null;
|
|
403
|
+
try {
|
|
404
|
+
let content = await fs.readFile(target.filePath, 'utf8');
|
|
405
|
+
let data = JSON.parse(content);
|
|
406
|
+
let tokenObj = data.token || {};
|
|
407
|
+
let accessToken = tokenObj.access_token || data.access_token || '';
|
|
408
|
+
const expiry = parseExpiryToMs(tokenObj.expiry || data.expiry);
|
|
409
|
+
const now = Date.now();
|
|
410
|
+
if ((!accessToken || !expiry || expiry - now < 60 * 1000) && target.hasRefreshToken) {
|
|
411
|
+
const refRes = await this.refreshTokenForAccount(target.name);
|
|
412
|
+
if (refRes.success) {
|
|
413
|
+
content = await fs.readFile(target.filePath, 'utf8');
|
|
414
|
+
data = JSON.parse(content);
|
|
415
|
+
tokenObj = data.token || {};
|
|
416
|
+
accessToken = tokenObj.access_token || data.access_token || '';
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (!accessToken)
|
|
420
|
+
return null;
|
|
421
|
+
let res = await this.fetchFn(GOOGLE_QUOTA_ENDPOINT, {
|
|
422
|
+
method: 'POST',
|
|
423
|
+
headers: {
|
|
424
|
+
Authorization: `Bearer ${accessToken}`,
|
|
425
|
+
'Content-Type': 'application/json',
|
|
426
|
+
'User-Agent': 'antigravity',
|
|
427
|
+
},
|
|
428
|
+
body: '{}',
|
|
429
|
+
});
|
|
430
|
+
if (res.status === 401 && target.hasRefreshToken) {
|
|
431
|
+
const refRes = await this.refreshTokenForAccount(target.name);
|
|
432
|
+
if (refRes.success) {
|
|
433
|
+
content = await fs.readFile(target.filePath, 'utf8');
|
|
434
|
+
data = JSON.parse(content);
|
|
435
|
+
tokenObj = data.token || {};
|
|
436
|
+
accessToken = tokenObj.access_token || data.access_token || '';
|
|
437
|
+
if (accessToken) {
|
|
438
|
+
res = await this.fetchFn(GOOGLE_QUOTA_ENDPOINT, {
|
|
439
|
+
method: 'POST',
|
|
440
|
+
headers: {
|
|
441
|
+
Authorization: `Bearer ${accessToken}`,
|
|
442
|
+
'Content-Type': 'application/json',
|
|
443
|
+
'User-Agent': 'antigravity',
|
|
444
|
+
},
|
|
445
|
+
body: '{}',
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (!res.ok) {
|
|
451
|
+
this.logger?.warn('antigravity.auth.quota_fetch_failed', {
|
|
452
|
+
account: target.name,
|
|
453
|
+
status: res.status,
|
|
454
|
+
});
|
|
455
|
+
return cached ?? null;
|
|
456
|
+
}
|
|
457
|
+
const resJson = (await res.json());
|
|
458
|
+
let fiveHourPercent = null;
|
|
459
|
+
let weeklyPercent = null;
|
|
460
|
+
let resetTime5h = null;
|
|
461
|
+
let resetTimeWeekly = null;
|
|
462
|
+
let thirdParty5hPercent = null;
|
|
463
|
+
let thirdPartyWeeklyPercent = null;
|
|
464
|
+
if (resJson.userQuotaSummary) {
|
|
465
|
+
const u = resJson.userQuotaSummary;
|
|
466
|
+
if (typeof u.fiveHourPercent === 'number')
|
|
467
|
+
fiveHourPercent = u.fiveHourPercent;
|
|
468
|
+
if (typeof u.weeklyPercent === 'number')
|
|
469
|
+
weeklyPercent = u.weeklyPercent;
|
|
470
|
+
if (typeof u.resetTime5h === 'string')
|
|
471
|
+
resetTime5h = u.resetTime5h;
|
|
472
|
+
if (typeof u.resetTimeWeekly === 'string')
|
|
473
|
+
resetTimeWeekly = u.resetTimeWeekly;
|
|
474
|
+
}
|
|
475
|
+
for (const group of resJson.groups || []) {
|
|
476
|
+
const isGemini = group.displayName === 'Gemini Models' ||
|
|
477
|
+
group.buckets?.some((b) => b.bucketId?.startsWith('gemini'));
|
|
478
|
+
const is3P = group.displayName === 'Claude and GPT models' ||
|
|
479
|
+
group.buckets?.some((b) => b.bucketId?.startsWith('3p'));
|
|
480
|
+
for (const b of group.buckets || []) {
|
|
481
|
+
const pct = typeof b.remainingFraction === 'number'
|
|
482
|
+
? Math.max(0, Math.min(100, Math.round(b.remainingFraction * 100)))
|
|
483
|
+
: null;
|
|
484
|
+
if (isGemini) {
|
|
485
|
+
if (b.window === '5h' || b.bucketId === 'gemini-5h') {
|
|
486
|
+
fiveHourPercent = pct;
|
|
487
|
+
resetTime5h = b.resetTime || null;
|
|
488
|
+
}
|
|
489
|
+
else if (b.window === 'weekly' || b.bucketId === 'gemini-weekly') {
|
|
490
|
+
weeklyPercent = pct;
|
|
491
|
+
resetTimeWeekly = b.resetTime || null;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
else if (is3P) {
|
|
495
|
+
if (b.window === '5h' || b.bucketId === '3p-5h') {
|
|
496
|
+
thirdParty5hPercent = pct;
|
|
497
|
+
}
|
|
498
|
+
else if (b.window === 'weekly' || b.bucketId === '3p-weekly') {
|
|
499
|
+
thirdPartyWeeklyPercent = pct;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const snapshot = {
|
|
505
|
+
fiveHourPercent,
|
|
506
|
+
weeklyPercent,
|
|
507
|
+
resetTime5h,
|
|
508
|
+
resetTimeWeekly,
|
|
509
|
+
thirdParty5hPercent,
|
|
510
|
+
thirdPartyWeeklyPercent,
|
|
511
|
+
updatedAt: Date.now(),
|
|
512
|
+
};
|
|
513
|
+
this.quotaCache.set(target.name.toLowerCase(), snapshot);
|
|
514
|
+
if (target.email) {
|
|
515
|
+
this.quotaCache.set(target.email.toLowerCase(), snapshot);
|
|
516
|
+
}
|
|
517
|
+
return snapshot;
|
|
518
|
+
}
|
|
519
|
+
catch (err) {
|
|
520
|
+
this.logger?.warn('antigravity.auth.quota_fetch_error', {
|
|
521
|
+
account: target.name,
|
|
522
|
+
error: String(err),
|
|
523
|
+
});
|
|
524
|
+
return cached ?? null;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
async populateQuotas(candidates, force = false) {
|
|
528
|
+
await Promise.allSettled(candidates.map(async (acc) => {
|
|
529
|
+
acc.quota = await this.fetchQuotaForAccount(acc.name, force);
|
|
530
|
+
}));
|
|
531
|
+
}
|
|
532
|
+
async listCandidates(includeQuotas = false, forceQuotaRefresh = false) {
|
|
533
|
+
try {
|
|
534
|
+
const files = await fs.readdir(this.authDir);
|
|
535
|
+
const candidateNames = files.filter((f) => f.startsWith('antigravity-oauth-token_') && !f.endsWith('.tmp'));
|
|
536
|
+
const activeContent = await this.readActiveTokenContent();
|
|
537
|
+
const accounts = [];
|
|
538
|
+
for (const name of candidateNames) {
|
|
539
|
+
const filePath = path.join(this.authDir, name);
|
|
540
|
+
const account = await this.parseAccountFile(name, filePath, activeContent);
|
|
541
|
+
if (account) {
|
|
542
|
+
const cd = Math.max(this.getCooldownRemainingSec(account.name), account.email ? this.getCooldownRemainingSec(account.email) : 0);
|
|
543
|
+
account.isCooldown = cd > 0;
|
|
544
|
+
account.cooldownRemainingSec = cd;
|
|
545
|
+
account.isPaused = this.isAccountPaused(account.name) || (account.email ? this.isAccountPaused(account.email) : false);
|
|
546
|
+
account.quota = this.getCachedQuota(account.name, account.email);
|
|
547
|
+
accounts.push(account);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
const hasActiveMatch = accounts.some((a) => a.isActive);
|
|
551
|
+
if (!hasActiveMatch && activeContent) {
|
|
552
|
+
const defaultAccount = await this.parseAccountFile('active', this.activeTokenPath, activeContent);
|
|
553
|
+
if (defaultAccount) {
|
|
554
|
+
defaultAccount.isActive = true;
|
|
555
|
+
const cd = Math.max(this.getCooldownRemainingSec(defaultAccount.name), defaultAccount.email ? this.getCooldownRemainingSec(defaultAccount.email) : 0);
|
|
556
|
+
defaultAccount.isCooldown = cd > 0;
|
|
557
|
+
defaultAccount.cooldownRemainingSec = cd;
|
|
558
|
+
defaultAccount.isPaused = this.isAccountPaused(defaultAccount.name) || (defaultAccount.email ? this.isAccountPaused(defaultAccount.email) : false);
|
|
559
|
+
defaultAccount.quota = this.getCachedQuota(defaultAccount.name, defaultAccount.email);
|
|
560
|
+
accounts.unshift(defaultAccount);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
if (includeQuotas) {
|
|
564
|
+
await this.populateQuotas(accounts, forceQuotaRefresh);
|
|
565
|
+
}
|
|
566
|
+
return accounts;
|
|
567
|
+
}
|
|
568
|
+
catch (error) {
|
|
569
|
+
this.logger?.warn('antigravity.auth.list_failed', {
|
|
570
|
+
error: error instanceof Error ? error.message : String(error),
|
|
571
|
+
});
|
|
572
|
+
return [];
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
async getActiveAccount() {
|
|
576
|
+
const accounts = await this.listCandidates();
|
|
577
|
+
return accounts.find((a) => a.isActive) ?? accounts[0] ?? null;
|
|
578
|
+
}
|
|
579
|
+
async switchAccount(targetNameOrEmail) {
|
|
580
|
+
const accounts = await this.listCandidates();
|
|
581
|
+
const target = accounts.find((a) => a.name === targetNameOrEmail ||
|
|
582
|
+
a.name === `antigravity-oauth-token_${targetNameOrEmail}` ||
|
|
583
|
+
(a.email && a.email.toLowerCase() === targetNameOrEmail.toLowerCase()));
|
|
584
|
+
if (!target) {
|
|
585
|
+
throw new Error(`Antigravity account '${targetNameOrEmail}' not found in candidate pool.`);
|
|
586
|
+
}
|
|
587
|
+
// If target token is expired or expiring within 5 min (300s), refresh it before switching
|
|
588
|
+
const now = Date.now();
|
|
589
|
+
if (target.hasRefreshToken && (!target.expiry || target.expiry - now < 300 * 1000)) {
|
|
590
|
+
const refreshed = await this.refreshTokenForAccount(target.name);
|
|
591
|
+
if (refreshed.success && refreshed.account) {
|
|
592
|
+
target.expiry = refreshed.newExpiry ?? null;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (target.filePath === this.activeTokenPath) {
|
|
596
|
+
return { success: true, account: target };
|
|
597
|
+
}
|
|
598
|
+
// Atomic replace activeTokenPath
|
|
599
|
+
await this.atomicCopy(target.filePath, this.activeTokenPath);
|
|
600
|
+
this.logger?.info('antigravity.auth.switched', {
|
|
601
|
+
name: target.name,
|
|
602
|
+
email: target.email,
|
|
603
|
+
});
|
|
604
|
+
target.isActive = true;
|
|
605
|
+
return { success: true, account: target };
|
|
606
|
+
}
|
|
607
|
+
async rotateNextCandidate(currentAccountName) {
|
|
608
|
+
const accounts = await this.listCandidates();
|
|
609
|
+
if (accounts.length <= 1) {
|
|
610
|
+
throw new Error('No alternative Antigravity accounts available for rotation.');
|
|
611
|
+
}
|
|
612
|
+
// Filter accounts not in cooldown and not paused
|
|
613
|
+
const available = accounts.filter((a) => !a.isCooldown && !a.isPaused);
|
|
614
|
+
const pool = available.length > 0 ? available : accounts.filter((a) => !a.isPaused);
|
|
615
|
+
if (pool.length === 0) {
|
|
616
|
+
throw new Error('All Antigravity accounts are currently paused.');
|
|
617
|
+
}
|
|
618
|
+
const currentIdx = pool.findIndex((a) => a.isActive || (currentAccountName && a.name === currentAccountName));
|
|
619
|
+
const nextIdx = (currentIdx + 1) % pool.length;
|
|
620
|
+
const nextAccount = pool[nextIdx];
|
|
621
|
+
return this.switchAccount(nextAccount.name);
|
|
622
|
+
}
|
|
623
|
+
async refreshTokenForAccount(targetNameOrEmail) {
|
|
624
|
+
const accounts = await this.listCandidates();
|
|
625
|
+
const target = accounts.find((a) => a.name.toLowerCase() === targetNameOrEmail.toLowerCase() ||
|
|
626
|
+
a.name === `antigravity-oauth-token_${targetNameOrEmail}` ||
|
|
627
|
+
(a.email && a.email.toLowerCase() === targetNameOrEmail.toLowerCase()) ||
|
|
628
|
+
(targetNameOrEmail === 'active' && a.isActive));
|
|
629
|
+
if (!target) {
|
|
630
|
+
return {
|
|
631
|
+
success: false,
|
|
632
|
+
error: `Account '${targetNameOrEmail}' not found in candidate pool.`,
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
try {
|
|
636
|
+
const content = await fs.readFile(target.filePath, 'utf8');
|
|
637
|
+
const data = JSON.parse(content);
|
|
638
|
+
const tokenObj = data.token || {};
|
|
639
|
+
const refreshToken = (typeof tokenObj.refresh_token === 'string' && tokenObj.refresh_token) ||
|
|
640
|
+
(typeof data.refresh_token === 'string' && data.refresh_token) ||
|
|
641
|
+
null;
|
|
642
|
+
if (!refreshToken) {
|
|
643
|
+
return {
|
|
644
|
+
success: false,
|
|
645
|
+
error: `No refresh_token found for account '${target.name}'.`,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
const params = new URLSearchParams({
|
|
649
|
+
client_id: GOOGLE_OAUTH_CLIENT_ID,
|
|
650
|
+
client_secret: GOOGLE_OAUTH_CLIENT_SECRET,
|
|
651
|
+
refresh_token: refreshToken,
|
|
652
|
+
grant_type: 'refresh_token',
|
|
653
|
+
});
|
|
654
|
+
const res = await this.fetchFn(GOOGLE_TOKEN_ENDPOINT, {
|
|
655
|
+
method: 'POST',
|
|
656
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
657
|
+
body: params.toString(),
|
|
658
|
+
});
|
|
659
|
+
if (!res.ok) {
|
|
660
|
+
const errText = await res.text();
|
|
661
|
+
this.logger?.warn('antigravity.auth.refresh_failed', {
|
|
662
|
+
account: target.name,
|
|
663
|
+
status: res.status,
|
|
664
|
+
error: errText,
|
|
665
|
+
});
|
|
666
|
+
return {
|
|
667
|
+
success: false,
|
|
668
|
+
error: `Google API returned status ${res.status}: ${errText}`,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
const resJson = (await res.json());
|
|
672
|
+
const newExpiryIso = new Date(Date.now() + (resJson.expires_in ?? 3600) * 1000).toISOString();
|
|
673
|
+
const updatedTokenObj = {
|
|
674
|
+
...tokenObj,
|
|
675
|
+
access_token: resJson.access_token,
|
|
676
|
+
token_type: resJson.token_type || tokenObj.token_type || 'Bearer',
|
|
677
|
+
expiry: newExpiryIso,
|
|
678
|
+
};
|
|
679
|
+
data.token = updatedTokenObj;
|
|
680
|
+
if (resJson.id_token) {
|
|
681
|
+
data.id_token = resJson.id_token;
|
|
682
|
+
}
|
|
683
|
+
await this.atomicWriteJson(target.filePath, data);
|
|
684
|
+
if (target.isActive || target.filePath === this.activeTokenPath) {
|
|
685
|
+
await this.atomicWriteJson(this.activeTokenPath, data);
|
|
686
|
+
}
|
|
687
|
+
this.clearCooldown(target.name);
|
|
688
|
+
if (target.email)
|
|
689
|
+
this.clearCooldown(target.email);
|
|
690
|
+
const refreshedExpiry = parseExpiryToMs(newExpiryIso);
|
|
691
|
+
const updatedAccounts = await this.listCandidates();
|
|
692
|
+
const updatedAcc = updatedAccounts.find((a) => a.name === target.name) || target;
|
|
693
|
+
this.logger?.info('antigravity.auth.refreshed', {
|
|
694
|
+
account: target.name,
|
|
695
|
+
email: target.email,
|
|
696
|
+
expiresIn: resJson.expires_in,
|
|
697
|
+
});
|
|
698
|
+
return {
|
|
699
|
+
success: true,
|
|
700
|
+
account: updatedAcc,
|
|
701
|
+
newExpiry: refreshedExpiry ?? undefined,
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
catch (err) {
|
|
705
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
706
|
+
this.logger?.warn('antigravity.auth.refresh_exception', {
|
|
707
|
+
account: target.name,
|
|
708
|
+
error: msg,
|
|
709
|
+
});
|
|
710
|
+
return {
|
|
711
|
+
success: false,
|
|
712
|
+
error: msg,
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
async refreshAllTokens() {
|
|
717
|
+
const candidates = await this.listCandidates();
|
|
718
|
+
let refreshed = 0;
|
|
719
|
+
let failed = 0;
|
|
720
|
+
const errors = {};
|
|
721
|
+
for (const c of candidates) {
|
|
722
|
+
if (!c.hasRefreshToken)
|
|
723
|
+
continue;
|
|
724
|
+
const res = await this.refreshTokenForAccount(c.name);
|
|
725
|
+
if (res.success) {
|
|
726
|
+
refreshed++;
|
|
727
|
+
}
|
|
728
|
+
else {
|
|
729
|
+
failed++;
|
|
730
|
+
errors[c.name] = res.error || 'Unknown error';
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return {
|
|
734
|
+
total: candidates.length,
|
|
735
|
+
refreshed,
|
|
736
|
+
failed,
|
|
737
|
+
errors,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
async ensureActiveTokenFresh(thresholdSec = 300) {
|
|
741
|
+
const active = await this.getActiveAccount();
|
|
742
|
+
if (!active || !active.hasRefreshToken)
|
|
743
|
+
return false;
|
|
744
|
+
const expiry = active.expiry;
|
|
745
|
+
const now = Date.now();
|
|
746
|
+
if (!expiry || expiry - now < thresholdSec * 1000) {
|
|
747
|
+
this.logger?.info('antigravity.auth.preflight_refresh', {
|
|
748
|
+
account: active.name,
|
|
749
|
+
remainingSec: expiry ? Math.round((expiry - now) / 1000) : 0,
|
|
750
|
+
});
|
|
751
|
+
const res = await this.refreshTokenForAccount(active.name);
|
|
752
|
+
return res.success;
|
|
753
|
+
}
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
async importAccountFromJson(rawJson, preferredName) {
|
|
757
|
+
try {
|
|
758
|
+
const data = JSON.parse(rawJson.trim());
|
|
759
|
+
const tokenObj = data.token || {};
|
|
760
|
+
const refreshToken = (typeof tokenObj.refresh_token === 'string' && tokenObj.refresh_token) ||
|
|
761
|
+
(typeof data.refresh_token === 'string' && data.refresh_token) ||
|
|
762
|
+
null;
|
|
763
|
+
if (!refreshToken) {
|
|
764
|
+
return {
|
|
765
|
+
success: false,
|
|
766
|
+
error: 'Missing refresh_token in credentials JSON.',
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
const idToken = (typeof data.id_token === 'string' && data.id_token) ||
|
|
770
|
+
(typeof tokenObj.id_token === 'string' && tokenObj.id_token) ||
|
|
771
|
+
undefined;
|
|
772
|
+
const email = parseEmailFromIdToken(idToken) || (typeof data.email === 'string' ? data.email : null);
|
|
773
|
+
let baseName = preferredName;
|
|
774
|
+
if (!baseName && email) {
|
|
775
|
+
baseName = email.replace(/[@.]/g, '_');
|
|
776
|
+
}
|
|
777
|
+
if (!baseName) {
|
|
778
|
+
baseName = `acc_${Date.now().toString(36)}`;
|
|
779
|
+
}
|
|
780
|
+
const cleanName = baseName.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
781
|
+
const targetFileName = `antigravity-oauth-token_${cleanName}`;
|
|
782
|
+
const destPath = path.join(this.authDir, targetFileName);
|
|
783
|
+
const standardData = {
|
|
784
|
+
auth_method: 'consumer',
|
|
785
|
+
id_token: idToken ?? null,
|
|
786
|
+
token: {
|
|
787
|
+
access_token: tokenObj.access_token || data.access_token || '',
|
|
788
|
+
token_type: tokenObj.token_type || data.token_type || 'Bearer',
|
|
789
|
+
refresh_token: refreshToken,
|
|
790
|
+
expiry: tokenObj.expiry || data.expiry || new Date(Date.now() + 3600 * 1000).toISOString(),
|
|
791
|
+
},
|
|
792
|
+
};
|
|
793
|
+
await this.atomicWriteJson(destPath, standardData);
|
|
794
|
+
const refreshRes = await this.refreshTokenForAccount(cleanName);
|
|
795
|
+
if (!refreshRes.success) {
|
|
796
|
+
await fs.unlink(destPath).catch(() => { });
|
|
797
|
+
return {
|
|
798
|
+
success: false,
|
|
799
|
+
error: `Token verification failed with Google: ${refreshRes.error}`,
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
return {
|
|
803
|
+
success: true,
|
|
804
|
+
account: refreshRes.account,
|
|
805
|
+
createdFileName: targetFileName,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
catch (err) {
|
|
809
|
+
return {
|
|
810
|
+
success: false,
|
|
811
|
+
error: `Invalid JSON format: ${err instanceof Error ? err.message : String(err)}`,
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
startKeepAlive(intervalMs = 30 * 60 * 1000) {
|
|
816
|
+
if (this.keepAliveTimer)
|
|
817
|
+
clearInterval(this.keepAliveTimer);
|
|
818
|
+
this.keepAliveTimer = setInterval(() => {
|
|
819
|
+
this.runKeepAliveCheck().catch((err) => {
|
|
820
|
+
this.logger?.warn('antigravity.auth.keepalive_error', { error: String(err) });
|
|
821
|
+
});
|
|
822
|
+
}, intervalMs);
|
|
823
|
+
this.keepAliveTimer?.unref?.();
|
|
824
|
+
const initialTimer = setTimeout(() => {
|
|
825
|
+
this.runKeepAliveCheck().catch(() => { });
|
|
826
|
+
}, 3000);
|
|
827
|
+
initialTimer?.unref?.();
|
|
828
|
+
}
|
|
829
|
+
stopKeepAlive() {
|
|
830
|
+
if (this.keepAliveTimer) {
|
|
831
|
+
clearInterval(this.keepAliveTimer);
|
|
832
|
+
this.keepAliveTimer = undefined;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
async runKeepAliveCheck() {
|
|
836
|
+
// Only refresh the active account to keep the active session warm without spamming Google OAuth
|
|
837
|
+
const active = await this.getActiveAccount();
|
|
838
|
+
if (!active || !active.hasRefreshToken)
|
|
839
|
+
return;
|
|
840
|
+
const now = Date.now();
|
|
841
|
+
if (!active.expiry || active.expiry - now < 15 * 60 * 1000) {
|
|
842
|
+
this.logger?.info('antigravity.auth.keepalive_refreshing_active', {
|
|
843
|
+
account: active.name,
|
|
844
|
+
email: active.email,
|
|
845
|
+
expiry: active.expiry ? new Date(active.expiry).toISOString() : 'none',
|
|
846
|
+
});
|
|
847
|
+
await this.refreshTokenForAccount(active.name);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
async parseAccountFile(name, filePath, activeContent) {
|
|
851
|
+
try {
|
|
852
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
853
|
+
const data = JSON.parse(content);
|
|
854
|
+
const idToken = typeof data.id_token === 'string' ? data.id_token : undefined;
|
|
855
|
+
const email = parseEmailFromIdToken(idToken);
|
|
856
|
+
const tokenObj = data.token;
|
|
857
|
+
const hasRefreshToken = Boolean(tokenObj?.refresh_token || data.refresh_token);
|
|
858
|
+
const expiry = parseExpiryToMs(tokenObj?.expiry || data.expiry);
|
|
859
|
+
const isActive = activeContent ? content.trim() === activeContent.trim() : false;
|
|
860
|
+
const cleanName = name.startsWith('antigravity-oauth-token_')
|
|
861
|
+
? name.slice('antigravity-oauth-token_'.length)
|
|
862
|
+
: name;
|
|
863
|
+
return {
|
|
864
|
+
name: cleanName,
|
|
865
|
+
filePath,
|
|
866
|
+
email,
|
|
867
|
+
isActive,
|
|
868
|
+
hasRefreshToken,
|
|
869
|
+
expiry,
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
catch {
|
|
873
|
+
return null;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
async readActiveTokenContent() {
|
|
877
|
+
try {
|
|
878
|
+
return await fs.readFile(this.activeTokenPath, 'utf8');
|
|
879
|
+
}
|
|
880
|
+
catch {
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
async atomicCopy(sourcePath, destPath) {
|
|
885
|
+
const tempPath = `${destPath}.${Date.now()}.${process.pid}.tmp`;
|
|
886
|
+
const content = await fs.readFile(sourcePath);
|
|
887
|
+
await fs.writeFile(tempPath, content, { mode: 0o600 });
|
|
888
|
+
await fs.rename(tempPath, destPath);
|
|
889
|
+
}
|
|
890
|
+
async atomicWriteJson(destPath, data) {
|
|
891
|
+
const tempPath = `${destPath}.${Date.now()}.${process.pid}.tmp`;
|
|
892
|
+
const content = JSON.stringify(data, null, 2);
|
|
893
|
+
await fs.writeFile(tempPath, content, { mode: 0o600, encoding: 'utf8' });
|
|
894
|
+
await fs.rename(tempPath, destPath);
|
|
895
|
+
}
|
|
896
|
+
}
|