diff --git a/README.md b/README.md index ec8e68c0..292bcb5d 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,44 @@ For details on load balancing, dual quota pools, and account storage, see [docs/ --- +## Proxy Configuration + +Use HTTP/HTTPS proxies for all API traffic (OAuth, token refresh, API calls). Useful for corporate environments or anti-detection setups. + +### Quick Start + +```bash +# Login with proxy - automatically saves to account +ANTIGRAVITY_LOGIN_PROXY=http://proxy.example.com:8080 opencode auth login + +# All future API calls from this account use the proxy +opencode run "Hello" --model=google/antigravity-claude-sonnet-4-5 +``` + +### How It Works + +1. **OAuth Login**: Set `ANTIGRAVITY_LOGIN_PROXY` during `opencode auth login` +2. **Auto-Save**: Proxy URL is saved to `~/.config/opencode/antigravity-accounts.json` +3. **All Traffic**: All API calls (token refresh, project discovery, API requests) use the proxy + +### Supported Formats + +- `http://proxy.example.com:8080` - Unauthenticated +- `http://user:pass@proxy.example.com:8080` - Authenticated +- `https://proxy.example.com:443` - TLS proxy + +**Note**: SOCKS5 proxies are NOT supported. Use HTTP/HTTPS only. + +### Security Warning + +⚠️ Proxy credentials are stored in **plaintext** in `antigravity-accounts.json` (same as OAuth tokens). Use filesystem permissions to protect this file. + +### Advanced: Manual Configuration + +See [docs/PROXY.md](docs/PROXY.md) for manual JSON editing and troubleshooting. + +--- + ## Troubleshoot > **Quick Reset**: Most issues can be resolved by deleting `~/.config/opencode/antigravity-accounts.json` and running `opencode auth login` again. diff --git a/docs/PROXY.md b/docs/PROXY.md new file mode 100644 index 00000000..4d023f7e --- /dev/null +++ b/docs/PROXY.md @@ -0,0 +1,140 @@ +# Proxy Configuration Example + +## OAuth Login Proxy (Recommended) + +**New in v1.3.4**: Use the `ANTIGRAVITY_LOGIN_PROXY` environment variable to configure proxy during account login. The proxy URL will be saved to the account automatically. + +```bash +# Login with proxy +ANTIGRAVITY_LOGIN_PROXY=http://proxy.example.com:8080 opencode auth login + +# Login with authenticated proxy +ANTIGRAVITY_LOGIN_PROXY=http://user:pass@proxy.example.com:8080 opencode auth login +``` + +The proxy URL is saved to `~/.config/opencode/antigravity-accounts.json` and used for: +- All OAuth token refreshes +- Project discovery API calls +- Gemini/Claude API requests +- Google Search tool requests +- Quota check requests + +**Benefits:** +- No manual JSON editing required +- Proxy is automatically associated with the account +- All future API calls from this account use the configured proxy + +## Manual Proxy Configuration (Alternative) + +Alternatively, you can manually edit your `~/.config/opencode/antigravity-accounts.json` file and add `proxyUrl` fields to each account: + +```json +{ + "version": 3, + "accounts": [ + { + "email": "user1@gmail.com", + "refreshToken": "1//0abc...", + "projectId": "my-project-1", + "proxyUrl": "http://user1:password1@proxy1.example.com:8080", + "addedAt": 1704067200000, + "lastUsed": 1704153600000 + }, + { + "email": "user2@gmail.com", + "refreshToken": "1//0def...", + "projectId": "my-project-2", + "proxyUrl": "http://user2:password2@proxy2.example.com:8080", + "addedAt": 1704067300000, + "lastUsed": 1704153700000 + }, + { + "email": "user3@gmail.com", + "refreshToken": "1//0ghi...", + "projectId": "my-project-3", + "proxyUrl": "https://proxy3.example.com:443", + "addedAt": 1704067400000, + "lastUsed": 1704153800000 + } + ], + "activeIndex": 0, + "activeIndexByFamily": { + "claude": 0, + "gemini": 1 + } +} +``` + +## Supported Proxy Formats + +- **HTTP**: `http://[user:pass@]host:port` +- **HTTPS**: `https://[user:pass@]host:port` + +**Note**: SOCKS5 proxies are NOT currently supported. Use HTTP/HTTPS proxies only. + +## Anti-Detection Features + +1. **Hard Fail**: If proxy fails, request fails immediately - NO direct fallback +2. **All Traffic**: Token refresh, project discovery, and API calls all use same proxy +3. **Per-Account Isolation**: Each account uses its own proxy → unique IP per account +4. **Connection Pooling**: Proxy connections are cached and reused for performance + +## Important Notes + +- **Credentials**: Proxy passwords stored in plaintext (same security level as OAuth tokens) +- **Backward Compatible**: Accounts without `proxyUrl` work unchanged (direct connection) +- **Restart Required**: Changes to `antigravity-accounts.json` require OpenCode restart +- **Error Handling**: Failed proxy connections mark account "cooling down" for 30 seconds + +## Testing Your Proxies + +Before adding proxies to all accounts, test one account first: + +```bash +# Test proxy during login +ANTIGRAVITY_LOGIN_PROXY=http://localhost:8080 opencode auth login + +# Watch your proxy logs - you should see: +# - POST https://oauth2.googleapis.com/token +# - GET https://www.googleapis.com/oauth2/v1/userinfo +# - POST to Antigravity loadCodeAssist endpoints + +# Make a test request +opencode run "Hello" --model=google/claude-sonnet-4-5 + +# Proxy logs should show API traffic +``` + +## Troubleshooting + +### Proxy Connection Failed + +``` +Error: Failed to create proxy agent for http://proxy:8080: connect ECONNREFUSED +``` + +**Solutions:** +- Check proxy URL format: `http://host:port` (not `https://` unless TLS-enabled proxy) +- Test proxy with curl: `curl -x http://proxy:8080 https://google.com` +- Ensure proxy is running and accessible +- Check firewall rules + +### Invalid Proxy URL Format + +``` +Error: Invalid proxy URL format: http://***:***@:invalid +``` + +**Solutions:** +- Ensure URL format is correct: `http://user:pass@host:port` +- Host must be valid hostname or IP address +- Port must be numeric + +### Account Cooldown After Proxy Failures + +If proxy fails 5 times consecutively, the account enters a 30-second cooldown to prevent cascading failures. + +**Solutions:** +- Fix proxy configuration +- Wait 30 seconds for cooldown to expire +- Check proxy logs for error details diff --git a/package-lock.json b/package-lock.json index e4f8a6b1..3ba1379f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@openauthjs/openauth": "^0.4.3", "@opencode-ai/plugin": "^0.15.30", "proper-lockfile": "^4.1.2", + "undici": "^6.23.0", "xdg-basedir": "^5.1.0", "zod": "^4.0.0" }, @@ -2352,6 +2353,15 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", + "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", diff --git a/package.json b/package.json index 69e21545..1c6391e8 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@opencode-ai/plugin": "^0.15.30", "@openauthjs/openauth": "^0.4.3", "proper-lockfile": "^4.1.2", + "undici": "^6.23.0", "xdg-basedir": "^5.1.0", "zod": "^4.0.0" } diff --git a/src/antigravity/oauth.ts b/src/antigravity/oauth.ts index 04847e01..02356a69 100644 --- a/src/antigravity/oauth.ts +++ b/src/antigravity/oauth.ts @@ -12,6 +12,7 @@ import { } from "../constants"; import { createLogger } from "../plugin/logger"; import { calculateTokenExpiry } from "../plugin/auth"; +import { fetchWithProxy } from "../plugin/proxy"; const log = createLogger("oauth"); @@ -119,10 +120,14 @@ async function fetchWithTimeout( url: string, options: RequestInit, timeoutMs = FETCH_TIMEOUT_MS, + proxyUrl?: string, ): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { + if (proxyUrl) { + return await fetchWithProxy(url, { ...options, signal: controller.signal }, proxyUrl); + } return await fetch(url, { ...options, signal: controller.signal }); } finally { clearTimeout(timeout); @@ -130,6 +135,7 @@ async function fetchWithTimeout( } async function fetchProjectID(accessToken: string): Promise { + const proxyUrl = process.env.ANTIGRAVITY_LOGIN_PROXY; const errors: string[] = []; const loadHeaders: Record = { Authorization: `Bearer ${accessToken}`, @@ -156,7 +162,7 @@ async function fetchProjectID(accessToken: string): Promise { pluginType: "GEMINI", }, }), - }); + }, undefined, proxyUrl); if (!response.ok) { const message = await response.text().catch(() => ""); @@ -203,11 +209,12 @@ export async function exchangeAntigravity( code: string, state: string, ): Promise { + const proxyUrl = process.env.ANTIGRAVITY_LOGIN_PROXY; try { const { verifier, projectId } = decodeState(state); const startTime = Date.now(); - const tokenResponse = await fetch("https://oauth2.googleapis.com/token", { + const tokenResponse = await fetchWithTimeout("https://oauth2.googleapis.com/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", @@ -224,7 +231,7 @@ export async function exchangeAntigravity( redirect_uri: ANTIGRAVITY_REDIRECT_URI, code_verifier: verifier, }), - }); + }, undefined, proxyUrl); if (!tokenResponse.ok) { const errorText = await tokenResponse.text(); @@ -233,7 +240,7 @@ export async function exchangeAntigravity( const tokenPayload = (await tokenResponse.json()) as AntigravityTokenResponse; - const userInfoResponse = await fetch( + const userInfoResponse = await fetchWithTimeout( "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", { headers: { @@ -242,6 +249,8 @@ export async function exchangeAntigravity( "X-Goog-Api-Client": GEMINI_CLI_HEADERS["X-Goog-Api-Client"], }, }, + undefined, + proxyUrl ); const userInfo = userInfoResponse.ok diff --git a/src/plugin.ts b/src/plugin.ts index f982cfde..c8d977b9 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -24,6 +24,7 @@ import { prepareAntigravityRequest, transformAntigravityResponse, } from "./plugin/request"; +import { fetchWithProxy } from "./plugin/proxy"; import { resolveModelWithTier } from "./plugin/transform/model-resolver"; import { isEmptyResponseBody, @@ -32,7 +33,7 @@ import { import { EmptyResponseError } from "./plugin/errors"; import { AntigravityTokenRefreshError, refreshAccessToken } from "./plugin/token"; import { startOAuthListener, type OAuthListener } from "./plugin/server"; -import { clearAccounts, loadAccounts, saveAccounts } from "./plugin/storage"; +import { clearAccounts, loadAccounts, saveAccounts, type AccountMetadataV3 } from "./plugin/storage"; import { AccountManager, type ModelFamily, parseRateLimitReason, calculateBackoffMs, computeSoftQuotaCacheTtlMs } from "./plugin/accounts"; import { createAutoUpdateCheckerHook } from "./hooks/auto-update-checker"; import { loadConfig, initRuntimeConfig, type AntigravityConfig } from "./plugin/config"; @@ -347,6 +348,7 @@ async function persistAccountPool( results: Array>, replaceAll: boolean = false, ): Promise { + const proxyUrl = process.env.ANTIGRAVITY_LOGIN_PROXY; if (results.length === 0) { return; } @@ -396,6 +398,7 @@ async function persistAccountPool( refreshToken: parts.refreshToken, projectId: parts.projectId, managedProjectId: parts.managedProjectId, + proxyUrl, addedAt: now, lastUsed: now, enabled: true, @@ -417,6 +420,7 @@ async function persistAccountPool( refreshToken: parts.refreshToken, projectId: parts.projectId ?? existing.projectId, managedProjectId: parts.managedProjectId ?? existing.managedProjectId, + proxyUrl: proxyUrl !== undefined ? proxyUrl : existing.proxyUrl, lastUsed: now, }; @@ -932,6 +936,10 @@ export const createAntigravityPlugin = (providerId: string) => async ( return "Error: No valid access token available. Please run `opencode auth login` to re-authenticate."; } + const accountManager = await AccountManager.loadFromDisk(auth); + const accounts = accountManager.getAccounts(); + const account = accounts.find(a => a.parts.refreshToken === parts.refreshToken); + return executeSearch( { query: args.query, @@ -941,6 +949,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( accessToken, projectId, ctx.abort, + account?.proxyUrl, ); }, }); @@ -1244,7 +1253,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( if (accessTokenExpired(authRecord)) { try { - const refreshed = await refreshAccessToken(authRecord, client, providerId); + const refreshed = await refreshAccessToken(authRecord, client, providerId, account.proxyUrl); if (!refreshed) { const { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index); getHealthTracker().recordFailure(account.index); @@ -1318,7 +1327,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( let projectContext: ProjectContextResult; try { - projectContext = await ensureProjectContext(authRecord); + projectContext = await ensureProjectContext(authRecord, account.proxyUrl); resetAccountFailureState(account.index); } catch (error) { const { failures, shouldCooldown, cooldownMs } = trackAccountFailure(account.index); @@ -1385,7 +1394,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( try { pushDebug("thinking-warmup: start"); - const warmupResponse = await fetch(warmupUrl, warmupInit); + const warmupResponse = await fetchWithProxy(warmupUrl, warmupInit, account.proxyUrl); const transformed = await transformAntigravityResponse( warmupResponse, true, @@ -1535,7 +1544,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( tokenConsumed = getTokenTracker().consume(account.index); } - const response = await fetch(prepared.request, prepared.init); + const response = await fetchWithProxy(resolvedUrl, prepared.init, account.proxyUrl); pushDebug(`status=${response.status} ${response.statusText}`); @@ -2386,6 +2395,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( refreshToken: parts.refreshToken, projectId: parts.projectId ?? updatedAccounts[refreshAccountIndex]?.projectId, managedProjectId: parts.managedProjectId ?? updatedAccounts[refreshAccountIndex]?.managedProjectId, + proxyUrl: process.env.ANTIGRAVITY_LOGIN_PROXY ?? updatedAccounts[refreshAccountIndex]?.proxyUrl, addedAt: updatedAccounts[refreshAccountIndex]?.addedAt ?? Date.now(), lastUsed: Date.now(), }; diff --git a/src/plugin/accounts.ts b/src/plugin/accounts.ts index bcdd07f1..74fb0e7a 100644 --- a/src/plugin/accounts.ts +++ b/src/plugin/accounts.ts @@ -160,6 +160,7 @@ export interface ManagedAccount { fingerprint?: import("./fingerprint").Fingerprint; /** History of previous fingerprints for this account */ fingerprintHistory?: FingerprintVersion[]; + proxyUrl?: string; /** Cached quota data from last checkAccountsQuota() call */ cachedQuota?: Partial>; cachedQuotaUpdatedAt?: number; @@ -374,6 +375,7 @@ export class AccountManager { fingerprint: acc.fingerprint ? updateFingerprintVersion(acc.fingerprint) : generateFingerprint(), + proxyUrl: acc.proxyUrl, cachedQuota: acc.cachedQuota as Partial> | undefined, cachedQuotaUpdatedAt: acc.cachedQuotaUpdatedAt, }; @@ -915,6 +917,7 @@ export class AccountManager { cooldownReason: a.cooldownReason, fingerprint: a.fingerprint, fingerprintHistory: a.fingerprintHistory?.length ? a.fingerprintHistory : undefined, + proxyUrl: a.proxyUrl, cachedQuota: a.cachedQuota && Object.keys(a.cachedQuota).length > 0 ? a.cachedQuota : undefined, cachedQuotaUpdatedAt: a.cachedQuotaUpdatedAt, })), diff --git a/src/plugin/project.ts b/src/plugin/project.ts index 092229e1..8454f0e0 100644 --- a/src/plugin/project.ts +++ b/src/plugin/project.ts @@ -6,6 +6,7 @@ import { } from "../constants"; import { formatRefreshParts, parseRefreshParts } from "./auth"; import { createLogger } from "./logger"; +import { fetchWithProxy } from "./proxy"; import type { OAuthAuthDetails, ProjectContextResult } from "./types"; const log = createLogger("project"); @@ -121,6 +122,7 @@ export function invalidateProjectContextCache(refresh?: string): void { export async function loadManagedProject( accessToken: string, projectId?: string, + proxyUrl?: string, ): Promise { const metadata = buildMetadata(projectId); const requestBody: Record = { metadata }; @@ -139,13 +141,14 @@ export async function loadManagedProject( for (const baseEndpoint of loadEndpoints) { try { - const response = await fetch( + const response = await fetchWithProxy( `${baseEndpoint}/v1internal:loadCodeAssist`, { method: "POST", headers: loadHeaders, body: JSON.stringify(requestBody), }, + proxyUrl, ); if (!response.ok) { @@ -170,6 +173,7 @@ export async function onboardManagedProject( accessToken: string, tierId: string, projectId?: string, + proxyUrl?: string, attempts = 10, delayMs = 5000, ): Promise { @@ -182,7 +186,7 @@ export async function onboardManagedProject( for (const baseEndpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) { for (let attempt = 0; attempt < attempts; attempt += 1) { try { - const response = await fetch( + const response = await fetchWithProxy( `${baseEndpoint}/v1internal:onboardUser`, { method: "POST", @@ -193,6 +197,7 @@ export async function onboardManagedProject( }, body: JSON.stringify(requestBody), }, + proxyUrl, ); if (!response.ok) { @@ -222,7 +227,7 @@ export async function onboardManagedProject( /** * Resolves an effective project ID for the current auth state, caching results per refresh token. */ -export async function ensureProjectContext(auth: OAuthAuthDetails): Promise { +export async function ensureProjectContext(auth: OAuthAuthDetails, proxyUrl?: string): Promise { const accessToken = auth.access; if (!accessToken) { return { auth, effectiveProjectId: "" }; @@ -261,7 +266,7 @@ export async function ensureProjectContext(auth: OAuthAuthDetails): Promise { + const ProxyAgentMock = vi.fn().mockImplementation((options) => ({ + uri: options.uri, + options, + })); + return { + ProxyAgent: ProxyAgentMock, + fetch: vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) } as Response), + }; +}); + +describe('proxy.ts', () => { + beforeEach(() => { + vi.clearAllMocks(); + // We can't easily clear the internal agentCache Map in proxy.ts + // because it's not exported. But we can test around it or + // use different URLs for different tests. + }); + + describe('getProxyAgent', () => { + it('returns undefined when proxyUrl is empty or undefined', () => { + expect(getProxyAgent()).toBeUndefined(); + expect(getProxyAgent('')).toBeUndefined(); + expect(getProxyAgent(' ')).toBeUndefined(); + }); + + it('returns a ProxyAgent instance for a valid URL', () => { + const proxyUrl = 'http://proxy.example.com:8080'; + const agent = getProxyAgent(proxyUrl); + + expect(agent).toBeDefined(); + expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ + uri: proxyUrl, + connect: { timeout: 30000 } + })); + }); + + it('throws an error with sanitized credentials for a malformed URL', () => { + const malformedUrlWithCreds = 'http://user:pass@malformed-url'; + // URL constructor will throw for things like 'not a url' + // But 'http://user:pass@malformed-url' might actually be valid for URL. + // Let's try something definitely invalid. + const invalidUrl = 'http://user:pass@:invalid'; + + expect(() => getProxyAgent(invalidUrl)).toThrow('Invalid proxy URL format: http://***:***@:invalid'); + }); + + it('caches the ProxyAgent instance for the same URL', () => { + const proxyUrl = 'http://cache.example.com'; + const agent1 = getProxyAgent(proxyUrl); + const agent2 = getProxyAgent(proxyUrl); + + expect(agent1).toBe(agent2); + expect(ProxyAgent).toHaveBeenCalledTimes(1); + }); + + it('returns different instances for different URLs', () => { + const proxyUrl1 = 'http://proxy1.example.com'; + const proxyUrl2 = 'http://proxy2.example.com'; + + const agent1 = getProxyAgent(proxyUrl1); + const agent2 = getProxyAgent(proxyUrl2); + + expect(agent1).not.toBe(agent2); + expect(ProxyAgent).toHaveBeenCalledTimes(2); + }); + + it('throws error when ProxyAgent creation fails', () => { + // Setup ProxyAgent to throw once + (ProxyAgent as any).mockImplementationOnce(() => { + throw new Error('Creation failed'); + }); + + const proxyUrl = 'http://fail.example.com'; + expect(() => getProxyAgent(proxyUrl)).toThrow('Failed to create proxy agent for http://fail.example.com: Creation failed'); + }); + + it('sanitizes credentials when ProxyAgent creation fails', () => { + (ProxyAgent as any).mockImplementationOnce(() => { + throw new Error('Access denied'); + }); + + const proxyUrl = 'http://user:pass@fail.example.com'; + expect(() => getProxyAgent(proxyUrl)).toThrow('Failed to create proxy agent for http://***:***@fail.example.com: Access denied'); + }); + }); + + describe('fetchWithProxy', () => { + it('uses global fetch when no proxy is provided', async () => { + const globalFetch = vi.fn().mockResolvedValue({ ok: true } as Response); + vi.stubGlobal('fetch', globalFetch); + + await fetchWithProxy('https://api.example.com'); + + expect(globalFetch).toHaveBeenCalledWith('https://api.example.com', undefined); + expect(undiciFetch).not.toHaveBeenCalled(); + }); + + it('uses undici fetch with ProxyAgent when proxy is provided', async () => { + const proxyUrl = 'http://proxy.example.com'; + const targetUrl = 'https://api.example.com'; + + await fetchWithProxy(targetUrl, { method: 'POST' }, proxyUrl); + + expect(undiciFetch).toHaveBeenCalledWith(targetUrl, expect.objectContaining({ + method: 'POST', + dispatcher: expect.any(Object) + })); + }); + }); +}); diff --git a/src/plugin/proxy.ts b/src/plugin/proxy.ts new file mode 100644 index 00000000..a2bba590 --- /dev/null +++ b/src/plugin/proxy.ts @@ -0,0 +1,94 @@ +import { ProxyAgent } from 'undici'; + +const MAX_AGENT_CACHE_SIZE = 50; // Prevent unbounded growth +const agentCache = new Map(); +const SUPPORTED_PROTOCOLS = new Set(['http:', 'https:']); + +function sanitizeCredentials(url: string): string { + return url.replace(/:\/\/[^@]+@/, '://***:***@'); +} + +/** + * Dispose a proxy agent and remove it from cache. + * Call this when an account is removed or its proxy URL changes. + */ +export function disposeProxyAgent(proxyUrl: string): void { + const normalizedUrl = proxyUrl.trim(); + const agent = agentCache.get(normalizedUrl); + if (agent) { + agent.close().catch(() => {}); + agentCache.delete(normalizedUrl); + } +} + +/** + * Clear oldest entries if cache exceeds max size (LRU eviction). + */ +function evictOldestIfNeeded(): void { + if (agentCache.size <= MAX_AGENT_CACHE_SIZE) return; + + const entriesToDelete = agentCache.size - MAX_AGENT_CACHE_SIZE; + const keys = Array.from(agentCache.keys()); + for (let i = 0; i < entriesToDelete; i++) { + const key = keys[i]; + if (key) { + const agent = agentCache.get(key); + agent?.close().catch(() => {}); + agentCache.delete(key); + } + } +} + +export function getProxyAgent(proxyUrl?: string): ProxyAgent | undefined { + if (!proxyUrl?.trim()) return undefined; + + const normalizedUrl = proxyUrl.trim(); + + let parsed: URL; + try { + parsed = new URL(normalizedUrl); + } catch { + throw new Error(`Invalid proxy URL format: ${sanitizeCredentials(normalizedUrl)}`); + } + + if (!SUPPORTED_PROTOCOLS.has(parsed.protocol)) { + throw new Error(`Unsupported proxy protocol: ${parsed.protocol} (only http: and https: supported)`); + } + + let agent = agentCache.get(normalizedUrl); + + if (!agent) { + try { + agent = new ProxyAgent({ + uri: normalizedUrl, + connect: { timeout: 30000 }, + }); + agentCache.set(normalizedUrl, agent); + evictOldestIfNeeded(); + } catch (error) { + const rawMessage = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to create proxy agent for ${sanitizeCredentials(normalizedUrl)}: ${sanitizeCredentials(rawMessage)}`); + } + } + + return agent; +} + +export async function fetchWithProxy( + input: string | URL, + init?: RequestInit, + proxyUrl?: string, +): Promise { + const agent = getProxyAgent(proxyUrl); + + if (!agent) { + return fetch(input, init); + } + + const { fetch: undiciFetch } = await import('undici'); + + const url = typeof input === 'string' ? input : input.href; + + // @ts-ignore - undici.fetch dispatcher property not in standard RequestInit + return undiciFetch(url, { ...init, dispatcher: agent }) as unknown as Promise; +} diff --git a/src/plugin/quota.ts b/src/plugin/quota.ts index 4c7cb6d5..fbbd64c6 100644 --- a/src/plugin/quota.ts +++ b/src/plugin/quota.ts @@ -4,6 +4,7 @@ import { ANTIGRAVITY_PROVIDER_ID, } from "../constants"; import { accessTokenExpired, formatRefreshParts, parseRefreshParts } from "./auth"; +import { fetchWithProxy } from "./proxy"; import { logQuotaFetch, logQuotaStatus } from "./debug"; import { ensureProjectContext } from "./project"; import { refreshAccessToken } from "./token"; @@ -172,11 +173,11 @@ function aggregateQuota(models?: Record): Quot return { groups, modelCount: totalCount }; } -async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs = FETCH_TIMEOUT_MS): Promise { +async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs = FETCH_TIMEOUT_MS, proxyUrl?: string): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { - return await fetch(url, { ...options, signal: controller.signal }); + return await fetchWithProxy(url, { ...options, signal: controller.signal }, proxyUrl); } finally { clearTimeout(timeout); } @@ -185,6 +186,7 @@ async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs = F async function fetchAvailableModels( accessToken: string, projectId: string, + proxyUrl?: string, ): Promise { const endpoint = ANTIGRAVITY_ENDPOINT_PROD; const quotaUserAgent = ANTIGRAVITY_HEADERS["User-Agent"] || "antigravity/windows/amd64"; @@ -199,7 +201,7 @@ async function fetchAvailableModels( "User-Agent": quotaUserAgent, }, body: JSON.stringify(body), - }); + }, FETCH_TIMEOUT_MS, proxyUrl); if (response.ok) { return (await response.json()) as FetchAvailableModelsResponse; @@ -217,6 +219,7 @@ async function fetchAvailableModels( async function fetchGeminiCliQuota( accessToken: string, projectId: string, + proxyUrl?: string, ): Promise { const endpoint = ANTIGRAVITY_ENDPOINT_PROD; // Use Gemini CLI user-agent to get CLI quota buckets (not Antigravity buckets) @@ -235,7 +238,7 @@ async function fetchGeminiCliQuota( "User-Agent": geminiCliUserAgent, }, body: JSON.stringify(body), - }); + }, FETCH_TIMEOUT_MS, proxyUrl); if (response.ok) { const data = (await response.json()) as RetrieveUserQuotaResponse; @@ -323,14 +326,14 @@ export async function checkAccountsQuota( try { if (accessTokenExpired(auth)) { - const refreshed = await refreshAccessToken(auth, client, providerId); + const refreshed = await refreshAccessToken(auth, client, providerId, account.proxyUrl); if (!refreshed) { throw new Error("Token refresh failed"); } auth = refreshed; } - const projectContext = await ensureProjectContext(auth); + const projectContext = await ensureProjectContext(auth, account.proxyUrl); auth = projectContext.auth; const updatedAccount = applyAccountUpdates(account, auth); @@ -339,9 +342,9 @@ export async function checkAccountsQuota( // Fetch both Antigravity and Gemini CLI quotas in parallel const [antigravityResponse, geminiCliResponse] = await Promise.all([ - fetchAvailableModels(auth.access ?? "", projectContext.effectiveProjectId) + fetchAvailableModels(auth.access ?? "", projectContext.effectiveProjectId, account.proxyUrl) .catch((error): FetchAvailableModelsResponse => ({ models: undefined })), - fetchGeminiCliQuota(auth.access ?? "", projectContext.effectiveProjectId), + fetchGeminiCliQuota(auth.access ?? "", projectContext.effectiveProjectId, account.proxyUrl), ]); // Process Antigravity quota diff --git a/src/plugin/search.ts b/src/plugin/search.ts index 4b6e9979..5f0e7de8 100644 --- a/src/plugin/search.ts +++ b/src/plugin/search.ts @@ -14,6 +14,7 @@ import { SEARCH_SYSTEM_INSTRUCTION, } from "../constants"; import { createLogger } from "./logger"; +import { fetchWithProxy } from "./proxy"; const log = createLogger("search"); @@ -225,6 +226,7 @@ export async function executeSearch( accessToken: string, projectId: string, abortSignal?: AbortSignal, + proxyUrl?: string, ): Promise { const { query, urls, thinking = true } = args; @@ -283,7 +285,7 @@ export async function executeSearch( }); try { - const response = await fetch(url, { + const response = await fetchWithProxy(url, { method: "POST", headers: { ...ANTIGRAVITY_HEADERS, @@ -292,7 +294,7 @@ export async function executeSearch( }, body: JSON.stringify(wrappedBody), signal: abortSignal ?? AbortSignal.timeout(SEARCH_TIMEOUT_MS), - }); + }, proxyUrl); if (!response.ok) { const errorText = await response.text(); diff --git a/src/plugin/storage.ts b/src/plugin/storage.ts index ee44f513..9cba3043 100644 --- a/src/plugin/storage.ts +++ b/src/plugin/storage.ts @@ -193,6 +193,7 @@ export interface AccountMetadataV3 { cooldownReason?: CooldownReason; /** Per-account device fingerprint for rate limit mitigation */ fingerprint?: import("./fingerprint").Fingerprint; + proxyUrl?: string; // URL like "http://user:pass@host:port" (SOCKS5 not supported) /** Cached soft quota data */ cachedQuota?: Record; cachedQuotaUpdatedAt?: number; diff --git a/src/plugin/token.ts b/src/plugin/token.ts index b2b1c89a..cedfdc5a 100644 --- a/src/plugin/token.ts +++ b/src/plugin/token.ts @@ -3,6 +3,7 @@ import { formatRefreshParts, parseRefreshParts, calculateTokenExpiry } from "./a import { clearCachedAuth, storeCachedAuth } from "./cache"; import { createLogger } from "./logger"; import { invalidateProjectContextCache } from "./project"; +import { fetchWithProxy } from "./proxy"; import type { OAuthAuthDetails, PluginClient, RefreshParts } from "./types"; const log = createLogger("token"); @@ -86,6 +87,7 @@ export async function refreshAccessToken( auth: OAuthAuthDetails, client: PluginClient, providerId: string, + proxyUrl?: string, ): Promise { const parts = parseRefreshParts(auth.refresh); if (!parts.refreshToken) { @@ -94,18 +96,22 @@ export async function refreshAccessToken( try { const startTime = Date.now(); - const response = await fetch("https://oauth2.googleapis.com/token", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", + const response = await fetchWithProxy( + "https://oauth2.googleapis.com/token", + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: parts.refreshToken, + client_id: ANTIGRAVITY_CLIENT_ID, + client_secret: ANTIGRAVITY_CLIENT_SECRET, + }), }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: parts.refreshToken, - client_id: ANTIGRAVITY_CLIENT_ID, - client_secret: ANTIGRAVITY_CLIENT_SECRET, - }), - }); + proxyUrl, + ); if (!response.ok) { let errorText: string | undefined;