@kamel-ahmed/proxy-claude 1.0.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/LICENSE +21 -0
- package/README.md +622 -0
- package/bin/cli.js +124 -0
- package/package.json +80 -0
- package/public/app.js +228 -0
- package/public/css/src/input.css +523 -0
- package/public/css/style.css +1 -0
- package/public/favicon.svg +10 -0
- package/public/index.html +381 -0
- package/public/js/components/account-manager.js +245 -0
- package/public/js/components/claude-config.js +420 -0
- package/public/js/components/dashboard/charts.js +589 -0
- package/public/js/components/dashboard/filters.js +362 -0
- package/public/js/components/dashboard/stats.js +110 -0
- package/public/js/components/dashboard.js +236 -0
- package/public/js/components/logs-viewer.js +100 -0
- package/public/js/components/models.js +36 -0
- package/public/js/components/server-config.js +349 -0
- package/public/js/config/constants.js +102 -0
- package/public/js/data-store.js +386 -0
- package/public/js/settings-store.js +58 -0
- package/public/js/store.js +78 -0
- package/public/js/translations/en.js +351 -0
- package/public/js/translations/id.js +396 -0
- package/public/js/translations/pt.js +287 -0
- package/public/js/translations/tr.js +342 -0
- package/public/js/translations/zh.js +357 -0
- package/public/js/utils/account-actions.js +189 -0
- package/public/js/utils/error-handler.js +96 -0
- package/public/js/utils/model-config.js +42 -0
- package/public/js/utils/validators.js +77 -0
- package/public/js/utils.js +69 -0
- package/public/views/accounts.html +329 -0
- package/public/views/dashboard.html +484 -0
- package/public/views/logs.html +97 -0
- package/public/views/models.html +331 -0
- package/public/views/settings.html +1329 -0
- package/src/account-manager/credentials.js +243 -0
- package/src/account-manager/index.js +380 -0
- package/src/account-manager/onboarding.js +117 -0
- package/src/account-manager/rate-limits.js +237 -0
- package/src/account-manager/storage.js +136 -0
- package/src/account-manager/strategies/base-strategy.js +104 -0
- package/src/account-manager/strategies/hybrid-strategy.js +195 -0
- package/src/account-manager/strategies/index.js +79 -0
- package/src/account-manager/strategies/round-robin-strategy.js +76 -0
- package/src/account-manager/strategies/sticky-strategy.js +138 -0
- package/src/account-manager/strategies/trackers/health-tracker.js +162 -0
- package/src/account-manager/strategies/trackers/index.js +8 -0
- package/src/account-manager/strategies/trackers/token-bucket-tracker.js +121 -0
- package/src/auth/database.js +169 -0
- package/src/auth/oauth.js +419 -0
- package/src/auth/token-extractor.js +117 -0
- package/src/cli/accounts.js +512 -0
- package/src/cli/refresh.js +201 -0
- package/src/cli/setup.js +338 -0
- package/src/cloudcode/index.js +29 -0
- package/src/cloudcode/message-handler.js +386 -0
- package/src/cloudcode/model-api.js +248 -0
- package/src/cloudcode/rate-limit-parser.js +181 -0
- package/src/cloudcode/request-builder.js +93 -0
- package/src/cloudcode/session-manager.js +47 -0
- package/src/cloudcode/sse-parser.js +121 -0
- package/src/cloudcode/sse-streamer.js +293 -0
- package/src/cloudcode/streaming-handler.js +492 -0
- package/src/config.js +107 -0
- package/src/constants.js +278 -0
- package/src/errors.js +238 -0
- package/src/fallback-config.js +29 -0
- package/src/format/content-converter.js +193 -0
- package/src/format/index.js +20 -0
- package/src/format/request-converter.js +248 -0
- package/src/format/response-converter.js +120 -0
- package/src/format/schema-sanitizer.js +673 -0
- package/src/format/signature-cache.js +88 -0
- package/src/format/thinking-utils.js +558 -0
- package/src/index.js +146 -0
- package/src/modules/usage-stats.js +205 -0
- package/src/server.js +861 -0
- package/src/utils/claude-config.js +245 -0
- package/src/utils/helpers.js +51 -0
- package/src/utils/logger.js +142 -0
- package/src/utils/native-module-helper.js +162 -0
- package/src/webui/index.js +707 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token Bucket Tracker
|
|
3
|
+
*
|
|
4
|
+
* Client-side rate limiting using the token bucket algorithm.
|
|
5
|
+
* Each account has a bucket of tokens that regenerate over time.
|
|
6
|
+
* Requests consume tokens; accounts without tokens are deprioritized.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Default configuration (matches opencode-antigravity-auth)
|
|
10
|
+
const DEFAULT_CONFIG = {
|
|
11
|
+
maxTokens: 50, // Maximum token capacity
|
|
12
|
+
tokensPerMinute: 6, // Regeneration rate
|
|
13
|
+
initialTokens: 50 // Starting tokens
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export class TokenBucketTracker {
|
|
17
|
+
#buckets = new Map(); // email -> { tokens, lastUpdated }
|
|
18
|
+
#config;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create a new TokenBucketTracker
|
|
22
|
+
* @param {Object} config - Token bucket configuration
|
|
23
|
+
*/
|
|
24
|
+
constructor(config = {}) {
|
|
25
|
+
this.#config = { ...DEFAULT_CONFIG, ...config };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get the current token count for an account
|
|
30
|
+
* @param {string} email - Account email
|
|
31
|
+
* @returns {number} Current token count (with regeneration applied)
|
|
32
|
+
*/
|
|
33
|
+
getTokens(email) {
|
|
34
|
+
const bucket = this.#buckets.get(email);
|
|
35
|
+
if (!bucket) {
|
|
36
|
+
return this.#config.initialTokens;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Apply token regeneration based on time elapsed
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
const minutesElapsed = (now - bucket.lastUpdated) / (1000 * 60);
|
|
42
|
+
const regenerated = minutesElapsed * this.#config.tokensPerMinute;
|
|
43
|
+
const currentTokens = Math.min(
|
|
44
|
+
this.#config.maxTokens,
|
|
45
|
+
bucket.tokens + regenerated
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
return currentTokens;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Check if an account has tokens available
|
|
53
|
+
* @param {string} email - Account email
|
|
54
|
+
* @returns {boolean} True if account has at least 1 token
|
|
55
|
+
*/
|
|
56
|
+
hasTokens(email) {
|
|
57
|
+
return this.getTokens(email) >= 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Consume a token from an account's bucket
|
|
62
|
+
* @param {string} email - Account email
|
|
63
|
+
* @returns {boolean} True if token was consumed, false if no tokens available
|
|
64
|
+
*/
|
|
65
|
+
consume(email) {
|
|
66
|
+
const currentTokens = this.getTokens(email);
|
|
67
|
+
if (currentTokens < 1) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
this.#buckets.set(email, {
|
|
72
|
+
tokens: currentTokens - 1,
|
|
73
|
+
lastUpdated: Date.now()
|
|
74
|
+
});
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Refund a token to an account's bucket (e.g., on request failure before processing)
|
|
80
|
+
* @param {string} email - Account email
|
|
81
|
+
*/
|
|
82
|
+
refund(email) {
|
|
83
|
+
const currentTokens = this.getTokens(email);
|
|
84
|
+
const newTokens = Math.min(
|
|
85
|
+
this.#config.maxTokens,
|
|
86
|
+
currentTokens + 1
|
|
87
|
+
);
|
|
88
|
+
this.#buckets.set(email, {
|
|
89
|
+
tokens: newTokens,
|
|
90
|
+
lastUpdated: Date.now()
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get the maximum token capacity
|
|
96
|
+
* @returns {number} Maximum tokens per bucket
|
|
97
|
+
*/
|
|
98
|
+
getMaxTokens() {
|
|
99
|
+
return this.#config.maxTokens;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Reset the bucket for an account
|
|
104
|
+
* @param {string} email - Account email
|
|
105
|
+
*/
|
|
106
|
+
reset(email) {
|
|
107
|
+
this.#buckets.set(email, {
|
|
108
|
+
tokens: this.#config.initialTokens,
|
|
109
|
+
lastUpdated: Date.now()
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Clear all tracked buckets
|
|
115
|
+
*/
|
|
116
|
+
clear() {
|
|
117
|
+
this.#buckets.clear();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export default TokenBucketTracker;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite Database Access Module
|
|
3
|
+
* Provides cross-platform database operations for Antigravity state.
|
|
4
|
+
*
|
|
5
|
+
* Uses better-sqlite3 for:
|
|
6
|
+
* - Windows compatibility (no CLI dependency)
|
|
7
|
+
* - Native performance
|
|
8
|
+
* - Synchronous API (simple error handling)
|
|
9
|
+
*
|
|
10
|
+
* Includes auto-rebuild capability for handling Node.js version updates
|
|
11
|
+
* that cause native module incompatibility.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createRequire } from 'module';
|
|
15
|
+
import { ANTIGRAVITY_DB_PATH } from '../constants.js';
|
|
16
|
+
import { isModuleVersionError, attemptAutoRebuild, clearRequireCache } from '../utils/native-module-helper.js';
|
|
17
|
+
import { logger } from '../utils/logger.js';
|
|
18
|
+
import { NativeModuleError } from '../errors.js';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
|
|
22
|
+
// Lazy-loaded Database constructor
|
|
23
|
+
let Database = null;
|
|
24
|
+
let moduleLoadError = null;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Load the better-sqlite3 module with auto-rebuild on version mismatch
|
|
28
|
+
* Uses synchronous require to maintain API compatibility
|
|
29
|
+
* @returns {Function} The Database constructor
|
|
30
|
+
* @throws {Error} If module cannot be loaded even after rebuild
|
|
31
|
+
*/
|
|
32
|
+
function loadDatabaseModule() {
|
|
33
|
+
// Return cached module if already loaded
|
|
34
|
+
if (Database) return Database;
|
|
35
|
+
|
|
36
|
+
// Re-throw cached error if previous load failed permanently
|
|
37
|
+
if (moduleLoadError) throw moduleLoadError;
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
Database = require('better-sqlite3');
|
|
41
|
+
return Database;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (isModuleVersionError(error)) {
|
|
44
|
+
logger.warn('[Database] Native module version mismatch detected');
|
|
45
|
+
|
|
46
|
+
if (attemptAutoRebuild(error)) {
|
|
47
|
+
// Clear require cache and retry
|
|
48
|
+
try {
|
|
49
|
+
const resolvedPath = require.resolve('better-sqlite3');
|
|
50
|
+
// Clear the module and all its dependencies from cache
|
|
51
|
+
clearRequireCache(resolvedPath, require.cache);
|
|
52
|
+
|
|
53
|
+
Database = require('better-sqlite3');
|
|
54
|
+
logger.success('[Database] Module reloaded successfully after rebuild');
|
|
55
|
+
return Database;
|
|
56
|
+
} catch (retryError) {
|
|
57
|
+
// Rebuild succeeded but reload failed - user needs to restart
|
|
58
|
+
moduleLoadError = new NativeModuleError(
|
|
59
|
+
'Native module rebuild completed. Please restart the server to apply the fix.',
|
|
60
|
+
true, // rebuildSucceeded
|
|
61
|
+
true // restartRequired
|
|
62
|
+
);
|
|
63
|
+
logger.info('[Database] Rebuild succeeded - server restart required');
|
|
64
|
+
throw moduleLoadError;
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
moduleLoadError = new NativeModuleError(
|
|
68
|
+
'Failed to auto-rebuild native module. Please run manually:\n' +
|
|
69
|
+
' npm rebuild better-sqlite3\n' +
|
|
70
|
+
'Or if using npx, find the package location in the error and run:\n' +
|
|
71
|
+
' cd /path/to/better-sqlite3 && npm rebuild',
|
|
72
|
+
false, // rebuildSucceeded
|
|
73
|
+
false // restartRequired
|
|
74
|
+
);
|
|
75
|
+
throw moduleLoadError;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Non-version-mismatch error, just throw it
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Query Antigravity database for authentication status
|
|
86
|
+
* @param {string} [dbPath] - Optional custom database path
|
|
87
|
+
* @returns {Object} Parsed auth data with apiKey, email, name, etc.
|
|
88
|
+
* @throws {Error} If database doesn't exist, query fails, or no auth status found
|
|
89
|
+
*/
|
|
90
|
+
export function getAuthStatus(dbPath = ANTIGRAVITY_DB_PATH) {
|
|
91
|
+
const Db = loadDatabaseModule();
|
|
92
|
+
let db;
|
|
93
|
+
try {
|
|
94
|
+
// Open database in read-only mode
|
|
95
|
+
db = new Db(dbPath, {
|
|
96
|
+
readonly: true,
|
|
97
|
+
fileMustExist: true
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Prepare and execute query
|
|
101
|
+
const stmt = db.prepare(
|
|
102
|
+
"SELECT value FROM ItemTable WHERE key = 'antigravityAuthStatus'"
|
|
103
|
+
);
|
|
104
|
+
const row = stmt.get();
|
|
105
|
+
|
|
106
|
+
if (!row || !row.value) {
|
|
107
|
+
throw new Error('No auth status found in database');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Parse JSON value
|
|
111
|
+
const authData = JSON.parse(row.value);
|
|
112
|
+
|
|
113
|
+
if (!authData.apiKey) {
|
|
114
|
+
throw new Error('Auth data missing apiKey field');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return authData;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
// Enhance error messages for common issues
|
|
120
|
+
if (error.code === 'SQLITE_CANTOPEN') {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Database not found at ${dbPath}. ` +
|
|
123
|
+
'Make sure Antigravity is installed and you are logged in.'
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
// Re-throw with context if not already our error
|
|
127
|
+
if (error.message.includes('No auth status') || error.message.includes('missing apiKey')) {
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
// Re-throw native module errors from loadDatabaseModule without wrapping
|
|
131
|
+
if (error instanceof NativeModuleError) {
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
throw new Error(`Failed to read Antigravity database: ${error.message}`);
|
|
135
|
+
} finally {
|
|
136
|
+
// Always close database connection
|
|
137
|
+
if (db) {
|
|
138
|
+
db.close();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Check if database exists and is accessible
|
|
145
|
+
* @param {string} [dbPath] - Optional custom database path
|
|
146
|
+
* @returns {boolean} True if database exists and can be opened
|
|
147
|
+
*/
|
|
148
|
+
export function isDatabaseAccessible(dbPath = ANTIGRAVITY_DB_PATH) {
|
|
149
|
+
let db;
|
|
150
|
+
try {
|
|
151
|
+
const Db = loadDatabaseModule();
|
|
152
|
+
db = new Db(dbPath, {
|
|
153
|
+
readonly: true,
|
|
154
|
+
fileMustExist: true
|
|
155
|
+
});
|
|
156
|
+
return true;
|
|
157
|
+
} catch {
|
|
158
|
+
return false;
|
|
159
|
+
} finally {
|
|
160
|
+
if (db) {
|
|
161
|
+
db.close();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export default {
|
|
167
|
+
getAuthStatus,
|
|
168
|
+
isDatabaseAccessible
|
|
169
|
+
};
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google OAuth with PKCE for Antigravity
|
|
3
|
+
*
|
|
4
|
+
* Implements the same OAuth flow as opencode-antigravity-auth
|
|
5
|
+
* to obtain refresh tokens for multiple Google accounts.
|
|
6
|
+
* Uses a local callback server to automatically capture the auth code.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import crypto from 'crypto';
|
|
10
|
+
import http from 'http';
|
|
11
|
+
import {
|
|
12
|
+
ANTIGRAVITY_ENDPOINT_FALLBACKS,
|
|
13
|
+
LOAD_CODE_ASSIST_HEADERS,
|
|
14
|
+
OAUTH_CONFIG,
|
|
15
|
+
OAUTH_REDIRECT_URI
|
|
16
|
+
} from '../constants.js';
|
|
17
|
+
import { logger } from '../utils/logger.js';
|
|
18
|
+
import { onboardUser, getDefaultTierId } from '../account-manager/onboarding.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Generate PKCE code verifier and challenge
|
|
22
|
+
*/
|
|
23
|
+
function generatePKCE() {
|
|
24
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
25
|
+
const challenge = crypto
|
|
26
|
+
.createHash('sha256')
|
|
27
|
+
.update(verifier)
|
|
28
|
+
.digest('base64url');
|
|
29
|
+
return { verifier, challenge };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Generate authorization URL for Google OAuth
|
|
34
|
+
* Returns the URL and the PKCE verifier (needed for token exchange)
|
|
35
|
+
*
|
|
36
|
+
* @param {string} [customRedirectUri] - Optional custom redirect URI (e.g. for WebUI)
|
|
37
|
+
* @returns {{url: string, verifier: string, state: string}} Auth URL and PKCE data
|
|
38
|
+
*/
|
|
39
|
+
export function getAuthorizationUrl(customRedirectUri = null) {
|
|
40
|
+
const { verifier, challenge } = generatePKCE();
|
|
41
|
+
const state = crypto.randomBytes(16).toString('hex');
|
|
42
|
+
|
|
43
|
+
const params = new URLSearchParams({
|
|
44
|
+
client_id: OAUTH_CONFIG.clientId,
|
|
45
|
+
redirect_uri: customRedirectUri || OAUTH_REDIRECT_URI,
|
|
46
|
+
response_type: 'code',
|
|
47
|
+
scope: OAUTH_CONFIG.scopes.join(' '),
|
|
48
|
+
access_type: 'offline',
|
|
49
|
+
prompt: 'consent',
|
|
50
|
+
code_challenge: challenge,
|
|
51
|
+
code_challenge_method: 'S256',
|
|
52
|
+
state: state
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
url: `${OAUTH_CONFIG.authUrl}?${params.toString()}`,
|
|
57
|
+
verifier,
|
|
58
|
+
state
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Extract authorization code and state from user input.
|
|
64
|
+
* User can paste either:
|
|
65
|
+
* - Full callback URL: http://localhost:51121/oauth-callback?code=xxx&state=xxx
|
|
66
|
+
* - Just the code parameter: 4/0xxx...
|
|
67
|
+
*
|
|
68
|
+
* @param {string} input - User input (URL or code)
|
|
69
|
+
* @returns {{code: string, state: string|null}} Extracted code and optional state
|
|
70
|
+
*/
|
|
71
|
+
export function extractCodeFromInput(input) {
|
|
72
|
+
if (!input || typeof input !== 'string') {
|
|
73
|
+
throw new Error('No input provided');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const trimmed = input.trim();
|
|
77
|
+
|
|
78
|
+
// Check if it looks like a URL
|
|
79
|
+
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
|
80
|
+
try {
|
|
81
|
+
const url = new URL(trimmed);
|
|
82
|
+
const code = url.searchParams.get('code');
|
|
83
|
+
const state = url.searchParams.get('state');
|
|
84
|
+
const error = url.searchParams.get('error');
|
|
85
|
+
|
|
86
|
+
if (error) {
|
|
87
|
+
throw new Error(`OAuth error: ${error}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!code) {
|
|
91
|
+
throw new Error('No authorization code found in URL');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { code, state };
|
|
95
|
+
} catch (e) {
|
|
96
|
+
if (e.message.includes('OAuth error') || e.message.includes('No authorization code')) {
|
|
97
|
+
throw e;
|
|
98
|
+
}
|
|
99
|
+
throw new Error('Invalid URL format');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Assume it's a raw code
|
|
104
|
+
// Google auth codes typically start with "4/" and are long
|
|
105
|
+
if (trimmed.length < 10) {
|
|
106
|
+
throw new Error('Input is too short to be a valid authorization code');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { code: trimmed, state: null };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Start a local server to receive the OAuth callback
|
|
114
|
+
* Returns a promise that resolves with the authorization code
|
|
115
|
+
*
|
|
116
|
+
* @param {string} expectedState - Expected state parameter for CSRF protection
|
|
117
|
+
* @param {number} timeoutMs - Timeout in milliseconds (default 120000)
|
|
118
|
+
* @returns {Promise<string>} Authorization code from OAuth callback
|
|
119
|
+
*/
|
|
120
|
+
export function startCallbackServer(expectedState, timeoutMs = 120000) {
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
const server = http.createServer((req, res) => {
|
|
123
|
+
const url = new URL(req.url, `http://localhost:${OAUTH_CONFIG.callbackPort}`);
|
|
124
|
+
|
|
125
|
+
if (url.pathname !== '/oauth-callback') {
|
|
126
|
+
res.writeHead(404);
|
|
127
|
+
res.end('Not found');
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const code = url.searchParams.get('code');
|
|
132
|
+
const state = url.searchParams.get('state');
|
|
133
|
+
const error = url.searchParams.get('error');
|
|
134
|
+
|
|
135
|
+
if (error) {
|
|
136
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
137
|
+
res.end(`
|
|
138
|
+
<html>
|
|
139
|
+
<head><meta charset="UTF-8"><title>Authentication Failed</title></head>
|
|
140
|
+
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
|
141
|
+
<h1 style="color: #dc3545;">❌ Authentication Failed</h1>
|
|
142
|
+
<p>Error: ${error}</p>
|
|
143
|
+
<p>You can close this window.</p>
|
|
144
|
+
</body>
|
|
145
|
+
</html>
|
|
146
|
+
`);
|
|
147
|
+
server.close();
|
|
148
|
+
reject(new Error(`OAuth error: ${error}`));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (state !== expectedState) {
|
|
153
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
154
|
+
res.end(`
|
|
155
|
+
<html>
|
|
156
|
+
<head><meta charset="UTF-8"><title>Authentication Failed</title></head>
|
|
157
|
+
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
|
158
|
+
<h1 style="color: #dc3545;">❌ Authentication Failed</h1>
|
|
159
|
+
<p>State mismatch - possible CSRF attack.</p>
|
|
160
|
+
<p>You can close this window.</p>
|
|
161
|
+
</body>
|
|
162
|
+
</html>
|
|
163
|
+
`);
|
|
164
|
+
server.close();
|
|
165
|
+
reject(new Error('State mismatch'));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!code) {
|
|
170
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
171
|
+
res.end(`
|
|
172
|
+
<html>
|
|
173
|
+
<head><meta charset="UTF-8"><title>Authentication Failed</title></head>
|
|
174
|
+
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
|
175
|
+
<h1 style="color: #dc3545;">❌ Authentication Failed</h1>
|
|
176
|
+
<p>No authorization code received.</p>
|
|
177
|
+
<p>You can close this window.</p>
|
|
178
|
+
</body>
|
|
179
|
+
</html>
|
|
180
|
+
`);
|
|
181
|
+
server.close();
|
|
182
|
+
reject(new Error('No authorization code'));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Success!
|
|
187
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
188
|
+
res.end(`
|
|
189
|
+
<html>
|
|
190
|
+
<head><meta charset="UTF-8"><title>Authentication Successful</title></head>
|
|
191
|
+
<body style="font-family: system-ui; padding: 40px; text-align: center;">
|
|
192
|
+
<h1 style="color: #28a745;">✅ Authentication Successful!</h1>
|
|
193
|
+
<p>You can close this window and return to the terminal.</p>
|
|
194
|
+
<script>setTimeout(() => window.close(), 2000);</script>
|
|
195
|
+
</body>
|
|
196
|
+
</html>
|
|
197
|
+
`);
|
|
198
|
+
|
|
199
|
+
server.close();
|
|
200
|
+
resolve(code);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
server.on('error', (err) => {
|
|
204
|
+
if (err.code === 'EADDRINUSE') {
|
|
205
|
+
reject(new Error(`Port ${OAUTH_CONFIG.callbackPort} is already in use. Close any other OAuth flows and try again.`));
|
|
206
|
+
} else {
|
|
207
|
+
reject(err);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
server.listen(OAUTH_CONFIG.callbackPort, () => {
|
|
212
|
+
logger.info(`[OAuth] Callback server listening on port ${OAUTH_CONFIG.callbackPort}`);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Timeout after specified duration
|
|
216
|
+
setTimeout(() => {
|
|
217
|
+
server.close();
|
|
218
|
+
reject(new Error('OAuth callback timeout - no response received'));
|
|
219
|
+
}, timeoutMs);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Exchange authorization code for tokens
|
|
225
|
+
*
|
|
226
|
+
* @param {string} code - Authorization code from OAuth callback
|
|
227
|
+
* @param {string} verifier - PKCE code verifier
|
|
228
|
+
* @returns {Promise<{accessToken: string, refreshToken: string, expiresIn: number}>} OAuth tokens
|
|
229
|
+
*/
|
|
230
|
+
export async function exchangeCode(code, verifier) {
|
|
231
|
+
const response = await fetch(OAUTH_CONFIG.tokenUrl, {
|
|
232
|
+
method: 'POST',
|
|
233
|
+
headers: {
|
|
234
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
235
|
+
},
|
|
236
|
+
body: new URLSearchParams({
|
|
237
|
+
client_id: OAUTH_CONFIG.clientId,
|
|
238
|
+
client_secret: OAUTH_CONFIG.clientSecret,
|
|
239
|
+
code: code,
|
|
240
|
+
code_verifier: verifier,
|
|
241
|
+
grant_type: 'authorization_code',
|
|
242
|
+
redirect_uri: OAUTH_REDIRECT_URI
|
|
243
|
+
})
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
if (!response.ok) {
|
|
247
|
+
const error = await response.text();
|
|
248
|
+
logger.error(`[OAuth] Token exchange failed: ${response.status} ${error}`);
|
|
249
|
+
throw new Error(`Token exchange failed: ${error}`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const tokens = await response.json();
|
|
253
|
+
|
|
254
|
+
if (!tokens.access_token) {
|
|
255
|
+
logger.error('[OAuth] No access token in response:', tokens);
|
|
256
|
+
throw new Error('No access token received');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
logger.info(`[OAuth] Token exchange successful, access_token length: ${tokens.access_token?.length}`);
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
accessToken: tokens.access_token,
|
|
263
|
+
refreshToken: tokens.refresh_token,
|
|
264
|
+
expiresIn: tokens.expires_in
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Refresh access token using refresh token
|
|
270
|
+
*
|
|
271
|
+
* @param {string} refreshToken - OAuth refresh token
|
|
272
|
+
* @returns {Promise<{accessToken: string, expiresIn: number}>} New access token
|
|
273
|
+
*/
|
|
274
|
+
export async function refreshAccessToken(refreshToken) {
|
|
275
|
+
const response = await fetch(OAUTH_CONFIG.tokenUrl, {
|
|
276
|
+
method: 'POST',
|
|
277
|
+
headers: {
|
|
278
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
279
|
+
},
|
|
280
|
+
body: new URLSearchParams({
|
|
281
|
+
client_id: OAUTH_CONFIG.clientId,
|
|
282
|
+
client_secret: OAUTH_CONFIG.clientSecret,
|
|
283
|
+
refresh_token: refreshToken,
|
|
284
|
+
grant_type: 'refresh_token'
|
|
285
|
+
})
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
if (!response.ok) {
|
|
289
|
+
const error = await response.text();
|
|
290
|
+
throw new Error(`Token refresh failed: ${error}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const tokens = await response.json();
|
|
294
|
+
return {
|
|
295
|
+
accessToken: tokens.access_token,
|
|
296
|
+
expiresIn: tokens.expires_in
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Get user email from access token
|
|
302
|
+
*
|
|
303
|
+
* @param {string} accessToken - OAuth access token
|
|
304
|
+
* @returns {Promise<string>} User's email address
|
|
305
|
+
*/
|
|
306
|
+
export async function getUserEmail(accessToken) {
|
|
307
|
+
const response = await fetch(OAUTH_CONFIG.userInfoUrl, {
|
|
308
|
+
headers: {
|
|
309
|
+
'Authorization': `Bearer ${accessToken}`
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
if (!response.ok) {
|
|
314
|
+
const errorText = await response.text();
|
|
315
|
+
logger.error(`[OAuth] getUserEmail failed: ${response.status} ${errorText}`);
|
|
316
|
+
throw new Error(`Failed to get user info: ${response.status}`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const userInfo = await response.json();
|
|
320
|
+
return userInfo.email;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Discover project ID for the authenticated user
|
|
325
|
+
*
|
|
326
|
+
* @param {string} accessToken - OAuth access token
|
|
327
|
+
* @returns {Promise<string|null>} Project ID or null if not found
|
|
328
|
+
*/
|
|
329
|
+
export async function discoverProjectId(accessToken) {
|
|
330
|
+
let loadCodeAssistData = null;
|
|
331
|
+
|
|
332
|
+
for (const endpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) {
|
|
333
|
+
try {
|
|
334
|
+
const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
|
|
335
|
+
method: 'POST',
|
|
336
|
+
headers: {
|
|
337
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
338
|
+
'Content-Type': 'application/json',
|
|
339
|
+
...LOAD_CODE_ASSIST_HEADERS
|
|
340
|
+
},
|
|
341
|
+
body: JSON.stringify({
|
|
342
|
+
metadata: {
|
|
343
|
+
ideType: 'IDE_UNSPECIFIED',
|
|
344
|
+
platform: 'PLATFORM_UNSPECIFIED',
|
|
345
|
+
pluginType: 'GEMINI'
|
|
346
|
+
}
|
|
347
|
+
})
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
if (!response.ok) continue;
|
|
351
|
+
|
|
352
|
+
const data = await response.json();
|
|
353
|
+
loadCodeAssistData = data;
|
|
354
|
+
|
|
355
|
+
if (typeof data.cloudaicompanionProject === 'string') {
|
|
356
|
+
return data.cloudaicompanionProject;
|
|
357
|
+
}
|
|
358
|
+
if (data.cloudaicompanionProject?.id) {
|
|
359
|
+
return data.cloudaicompanionProject.id;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// No project found - try to onboard
|
|
363
|
+
logger.info('[OAuth] No project in loadCodeAssist response, attempting onboardUser...');
|
|
364
|
+
break;
|
|
365
|
+
} catch (error) {
|
|
366
|
+
logger.warn(`[OAuth] Project discovery failed at ${endpoint}:`, error.message);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Try onboarding if we got a response but no project
|
|
371
|
+
if (loadCodeAssistData) {
|
|
372
|
+
const tierId = getDefaultTierId(loadCodeAssistData.allowedTiers) || 'FREE';
|
|
373
|
+
logger.info(`[OAuth] Onboarding user with tier: ${tierId}`);
|
|
374
|
+
|
|
375
|
+
const onboardedProject = await onboardUser(accessToken, tierId);
|
|
376
|
+
if (onboardedProject) {
|
|
377
|
+
logger.success(`[OAuth] Successfully onboarded, project: ${onboardedProject}`);
|
|
378
|
+
return onboardedProject;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Complete OAuth flow: exchange code and get all account info
|
|
387
|
+
*
|
|
388
|
+
* @param {string} code - Authorization code from OAuth callback
|
|
389
|
+
* @param {string} verifier - PKCE code verifier
|
|
390
|
+
* @returns {Promise<{email: string, refreshToken: string, accessToken: string, projectId: string|null}>} Complete account info
|
|
391
|
+
*/
|
|
392
|
+
export async function completeOAuthFlow(code, verifier) {
|
|
393
|
+
// Exchange code for tokens
|
|
394
|
+
const tokens = await exchangeCode(code, verifier);
|
|
395
|
+
|
|
396
|
+
// Get user email
|
|
397
|
+
const email = await getUserEmail(tokens.accessToken);
|
|
398
|
+
|
|
399
|
+
// Discover project ID
|
|
400
|
+
const projectId = await discoverProjectId(tokens.accessToken);
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
email,
|
|
404
|
+
refreshToken: tokens.refreshToken,
|
|
405
|
+
accessToken: tokens.accessToken,
|
|
406
|
+
projectId
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export default {
|
|
411
|
+
getAuthorizationUrl,
|
|
412
|
+
extractCodeFromInput,
|
|
413
|
+
startCallbackServer,
|
|
414
|
+
exchangeCode,
|
|
415
|
+
refreshAccessToken,
|
|
416
|
+
getUserEmail,
|
|
417
|
+
discoverProjectId,
|
|
418
|
+
completeOAuthFlow
|
|
419
|
+
};
|