diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bf6977..088f161 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ `_` names, which the shared MCP pipeline did not recognize. The provider now normalizes these to the canonical `mcp____` form so MCP breakdowns and `optimize` work correctly. Closes #308. +- **Antigravity Windows language-server discovery.** Antigravity detection now + supports Windows process discovery, `--extension_server_port`, + `--extension_server_csrf_token`, `--flag=value` syntax, and both wrapped and + unwrapped Connect-RPC response shapes. Closes #249. - **Mangled project names in dashboard.** The By Project and Top Sessions panels decoded slugs by splitting on `-`, which broke directory names containing dashes or dots (e.g. `my-project` rendered as `my/project`). diff --git a/docs/providers/antigravity.md b/docs/providers/antigravity.md index 723cef5..ca6d23f 100644 --- a/docs/providers/antigravity.md +++ b/docs/providers/antigravity.md @@ -3,41 +3,50 @@ Google Antigravity. The only provider that does not read files off disk: it speaks to a local language-server RPC endpoint instead. - **Source:** `src/providers/antigravity.ts` -- **Loading:** lazy (`src/providers/index.ts:14-27`). Lazy because the protobuf dependency is heavy. -- **Test:** none. Mocking the RPC endpoint cleanly is the open issue. +- **Loading:** lazy via `src/providers/index.ts`. Lazy because the protobuf dependency is heavy. +- **Test:** focused helper coverage in `tests/providers/antigravity.test.ts`. ## Where it reads from A local HTTPS RPC endpoint exposed by Antigravity's language server. The parser: -1. Locates the running language-server process via `ps`. +1. Locates the running language-server process via `ps` on POSIX or + `Get-CimInstance Win32_Process` on Windows. 2. Reads its port and CSRF token from process metadata. 3. Calls `GetCascadeTrajectoryGeneratorMetadata` over HTTPS. -4. Validates the response (capped at 5-15 MB depending on cascade size). +4. Validates the response (capped at 16 MB). -If the language server is not running, the parser falls back to the cached results file (`antigravity.ts:262-272`). +Antigravity exposes slightly different process flags across platforms: +POSIX builds have used `--https_server_port` and `--csrf_token`; Windows +builds can expose `--extension_server_port` and +`--extension_server_csrf_token`. Both space-separated and `--flag=value` +forms are supported. + +If the language server is not running, the parser falls back to the cached results file. ## Storage format -Protobuf. Cascade and response objects map to `ParsedProviderCall` directly; see `antigravity.ts:299-323`. +Protobuf. Cascade and response objects map to `ParsedProviderCall` directly. ## Caching -Custom file cache at `$CODEBURN_CACHE_DIR/antigravity-results.json` (defaults to `~/.cache/codeburn/`). The version constant is at `antigravity.ts:12`; the cache machinery (`loadCache`, `flushCache`) lives in `antigravity.ts:75-125`. The cache is also used as the data source when the RPC endpoint is unavailable, not just as an optimization. Bumping the cache version forces a recompute. +Custom file cache at `$CODEBURN_CACHE_DIR/antigravity-results.json` (defaults to `~/.cache/codeburn/`). The cache is also used as the data source when the RPC endpoint is unavailable, not just as an optimization. Bumping the cache version forces a recompute. ## Deduplication -Per `:` (`antigravity.ts:308`). +Per `:`. ## Quirks - **Antigravity is the only provider that requires a live process.** A user who closes Antigravity loses the most-recent data until next launch (the cache covers older runs). -- The 5-15 MB cap on RPC responses is necessary because individual cascades can balloon. Raising it risks OOM on the user's machine. -- Token types are split across `inputTokens`, `responseOutputTokens`, and `thinkingOutputTokens` (`antigravity.ts:313-323`). Thinking is billed at output rate. +- The 16 MB cap on RPC responses is necessary because individual cascades can balloon. Raising it risks OOM on the user's machine. +- Token types are split across `inputTokens`, `responseOutputTokens`, and `thinkingOutputTokens`. Thinking is billed at output rate. ## When fixing a bug here -1. Reproducing requires Antigravity running locally. There is no fixture for the RPC, which is a real testing gap. +1. Reproducing the full provider path requires Antigravity running locally. + The unit tests cover process flag parsing and wrapped/unwrapped RPC response + extraction, but they do not stand up a live Antigravity RPC endpoint. 2. Before any change, capture a sample protobuf response (anonymized) so future regressions can be tested against a recording. -3. If the bug is "no data after Antigravity update", the protobuf schema may have shifted. The parser's response handling at `antigravity.ts:299-323` is the place to look. +3. If the bug is "no data after Antigravity update", the protobuf schema may have shifted. The parser's response handling is the place to look. 4. If the bug is "stale data", check whether the RPC is reachable; the cache fallback can mask connectivity issues. diff --git a/src/providers/antigravity.ts b/src/providers/antigravity.ts index 3f9667e..95f96c9 100644 --- a/src/providers/antigravity.ts +++ b/src/providers/antigravity.ts @@ -14,7 +14,7 @@ const CACHE_VERSION = 2 const RPC_TIMEOUT_MS = 5000 const MAX_RESPONSE_BYTES = 16 * 1024 * 1024 -type ServerInfo = { +export type ServerInfo = { port: number csrfToken: string } @@ -31,7 +31,7 @@ type UsageEntry = { responseId?: string } -type GeneratorMetadata = { +export type GeneratorMetadata = { stepIndices?: number[] chatModel?: { model: string @@ -42,6 +42,20 @@ type GeneratorMetadata = { } } +type ModelMapResponse = { + models?: Record + response?: { + models?: Record + } +} + +type GeneratorMetadataResponse = { + generatorMetadata?: GeneratorMetadata[] + response?: { + generatorMetadata?: GeneratorMetadata[] + } +} + type CachedCascade = { mtimeMs: number sizeBytes: number @@ -59,6 +73,9 @@ let memCache: AntigravityCache | null = null let cacheDirty = false let httpsAgent: https.Agent | undefined +const SERVER_PORT_FLAGS = ['https_server_port', 'extension_server_port'] +const CSRF_TOKEN_FLAGS = ['csrf_token', 'extension_server_csrf_token'] + function getAgent(): https.Agent { if (!httpsAgent) httpsAgent = new https.Agent({ rejectUnauthorized: false }) return httpsAgent @@ -72,6 +89,72 @@ function getCachePath(): string { return join(getCacheDir(), 'antigravity-results.json') } +function execFileText(command: string, args: string[], timeout = 3000): Promise { + return new Promise((resolve, reject) => { + execFile(command, args, { encoding: 'utf-8', timeout, maxBuffer: 1024 * 1024 }, (err, stdout) => { + if (err) reject(err) + else resolve(stdout) + }) + }) +} + +function getFlagValue(line: string, names: string[]): string | null { + for (const name of names) { + const match = line.match(new RegExp(`--${name}(?:=|\\s+)(?:"([^"]+)"|'([^']+)'|([^\\s]+))`, 'i')) + const value = match?.[1] ?? match?.[2] ?? match?.[3] + if (value && !value.startsWith('--')) return value + } + return null +} + +function isLikelyCsrfToken(value: string): boolean { + return value.length >= 16 && /^[A-Za-z0-9._~:/+=-]+$/.test(value) +} + +export function parseAntigravityServerInfoFromLine(line: string): ServerInfo | null { + const lower = line.toLowerCase() + if (!lower.includes('language_server') || !lower.includes('antigravity')) return null + + const rawPort = getFlagValue(line, SERVER_PORT_FLAGS) + const csrfToken = getFlagValue(line, CSRF_TOKEN_FLAGS) + if (!rawPort || !csrfToken) return null + if (!isLikelyCsrfToken(csrfToken)) return null + + const port = Number(rawPort) + if (!Number.isInteger(port) || port <= 0 || port > 65535) return null + + return { port, csrfToken } +} + +export function parseAntigravityServerInfo(lines: string[]): ServerInfo | null { + for (const line of lines) { + const server = parseAntigravityServerInfoFromLine(line) + if (server) return server + } + return null +} + +export function extractAntigravityModelMap(resp: unknown): ModelMap { + if (!resp || typeof resp !== 'object') return {} + const data = resp as ModelMapResponse + const models = data.response?.models ?? data.models + const map: ModelMap = {} + if (!models) return map + for (const [key, info] of Object.entries(models)) { + if (info && typeof info === 'object' && typeof info.model === 'string') { + map[info.model] = key + } + } + return map +} + +export function extractAntigravityGeneratorMetadata(resp: unknown): GeneratorMetadata[] { + if (!resp || typeof resp !== 'object') return [] + const data = resp as GeneratorMetadataResponse + const metadata = data.response?.generatorMetadata ?? data.generatorMetadata + return Array.isArray(metadata) ? metadata : [] +} + async function loadCache(): Promise { if (memCache) return memCache try { @@ -124,27 +207,27 @@ async function flushCache(liveCascadeIds?: Set): Promise { } catch { /* best-effort */ } } +async function readProcessCommandLines(): Promise { + if (process.platform === 'win32') { + const script = [ + "$ErrorActionPreference = 'SilentlyContinue'", + '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', + "Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -and $_.CommandLine -like '*language_server*' -and $_.CommandLine -like '*antigravity*' } | ForEach-Object { $_.CommandLine }", + ].join('; ') + const output = await execFileText('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], 5000) + return output.split(/\r?\n/) + } + + const output = await execFileText('ps', ['-ww', '-eo', 'args']) + return output.split('\n') +} + async function detectServer(): Promise { if (cachedServer !== undefined) return cachedServer try { - const output = await new Promise((resolve, reject) => { - execFile('ps', ['-eo', 'args'], { encoding: 'utf-8', timeout: 3000 }, (err, stdout) => { - if (err) reject(err) - else resolve(stdout) - }) - }) - for (const line of output.split('\n')) { - if (!line.includes('language_server') || !line.includes('antigravity')) continue - if (!line.includes('--https_server_port')) continue - - const csrfMatch = line.match(/--csrf_token\s+([0-9a-f-]{32,})/) - const portMatch = line.match(/--https_server_port\s+(\d+)/) - if (csrfMatch && portMatch) { - cachedServer = { csrfToken: csrfMatch[1]!, port: parseInt(portMatch[1]!, 10) } - return cachedServer - } - } - } catch { /* ps failed or timed out */ } + cachedServer = parseAntigravityServerInfo(await readProcessCommandLines()) + return cachedServer + } catch { /* process discovery failed or timed out */ } cachedServer = null return null } @@ -199,20 +282,12 @@ async function rpc(server: ServerInfo, method: string, body: Record { if (cachedModelMap) return cachedModelMap - const map: ModelMap = {} try { - const resp = await rpc(server, 'GetAvailableModels') as { - response?: { models?: Record } - } - const models = resp?.response?.models - if (models) { - for (const [key, info] of Object.entries(models)) { - if (info.model) map[info.model] = key - } - } + cachedModelMap = extractAntigravityModelMap(await rpc(server, 'GetAvailableModels')) + return cachedModelMap } catch { /* best-effort */ } - cachedModelMap = map - return map + cachedModelMap = {} + return cachedModelMap } // Strip Antigravity-specific suffixes so the pricing DB can match @@ -275,10 +350,9 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars let metadata: GeneratorMetadata[] try { - const resp = await rpc(server, 'GetCascadeTrajectoryGeneratorMetadata', { cascadeId }) as { - generatorMetadata?: GeneratorMetadata[] - } - metadata = resp?.generatorMetadata ?? [] + metadata = extractAntigravityGeneratorMetadata( + await rpc(server, 'GetCascadeTrajectoryGeneratorMetadata', { cascadeId }), + ) } catch { if (cached) { for (const call of cached.calls) { diff --git a/tests/providers/antigravity.test.ts b/tests/providers/antigravity.test.ts new file mode 100644 index 0000000..9396c37 --- /dev/null +++ b/tests/providers/antigravity.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest' + +import { + extractAntigravityGeneratorMetadata, + extractAntigravityModelMap, + parseAntigravityServerInfo, + parseAntigravityServerInfoFromLine, +} from '../../src/providers/antigravity.js' + +describe('antigravity provider helpers', () => { + it('parses legacy https server flags from POSIX process args', () => { + const server = parseAntigravityServerInfoFromLine( + '/Applications/Antigravity.app/language_server_macos_arm --app_data_dir antigravity --https_server_port 57101 --csrf_token 01234567-89ab-cdef-0123-456789abcdef', + ) + + expect(server).toEqual({ + port: 57101, + csrfToken: '01234567-89ab-cdef-0123-456789abcdef', + }) + }) + + it('parses Windows extension server flags and equals syntax', () => { + const server = parseAntigravityServerInfoFromLine( + 'C:\\Users\\Admin\\AppData\\Local\\Programs\\Antigravity\\resources\\app\\extensions\\antigravity\\bin\\language_server_windows_x64.exe --extension_server_port=62225 --extension_server_csrf_token=abcdef01-2345-6789-abcd-ef0123456789', + ) + + expect(server).toEqual({ + port: 62225, + csrfToken: 'abcdef01-2345-6789-abcd-ef0123456789', + }) + }) + + it('parses Windows extension server flags and space syntax', () => { + const server = parseAntigravityServerInfo([ + 'node something-unrelated', + 'language_server_windows_x64.exe --app_data_dir C:\\Users\\Admin\\.gemini\\antigravity --extension_server_port 62300 --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543210', + ]) + + expect(server).toEqual({ + port: 62300, + csrfToken: 'fedcba98-7654-3210-fedc-ba9876543210', + }) + }) + + it('parses quoted flag values', () => { + const server = parseAntigravityServerInfoFromLine( + 'Antigravity language_server_windows_x64.exe --extension_server_port "62301" --extension_server_csrf_token "fedcba98-7654-3210-fedc-ba9876543211"', + ) + + expect(server).toEqual({ + port: 62301, + csrfToken: 'fedcba98-7654-3210-fedc-ba9876543211', + }) + }) + + it('matches language-server and antigravity markers case-insensitively', () => { + const server = parseAntigravityServerInfoFromLine( + 'ANTIGRAVITY LANGUAGE_SERVER_WINDOWS_X64.EXE --extension_server_port 62302 --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543212', + ) + + expect(server).toEqual({ + port: 62302, + csrfToken: 'fedcba98-7654-3210-fedc-ba9876543212', + }) + }) + + it('ignores process args without an antigravity marker', () => { + expect(parseAntigravityServerInfoFromLine( + 'language_server --extension_server_port 62300 --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543210', + )).toBeNull() + }) + + it('ignores invalid ports', () => { + expect(parseAntigravityServerInfoFromLine( + 'antigravity language_server --extension_server_port 99999 --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543210', + )).toBeNull() + }) + + it('ignores chained flag names as values', () => { + expect(parseAntigravityServerInfoFromLine( + 'antigravity language_server --extension_server_port=--extension_server_csrf_token --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543210', + )).toBeNull() + }) + + it('ignores implausibly short CSRF tokens', () => { + expect(parseAntigravityServerInfoFromLine( + 'antigravity language_server --extension_server_port 62300 --extension_server_csrf_token short', + )).toBeNull() + }) + + it('extracts model maps from wrapped and unwrapped RPC responses', () => { + expect(extractAntigravityModelMap({ + response: { models: { high: { model: 'MODEL_PLACEHOLDER_M7' } } }, + })).toEqual({ MODEL_PLACEHOLDER_M7: 'high' }) + + expect(extractAntigravityModelMap({ + models: { low: { model: 'MODEL_PLACEHOLDER_M8' } }, + })).toEqual({ MODEL_PLACEHOLDER_M8: 'low' }) + expect(extractAntigravityModelMap({ + models: { bad: null, good: { model: 'MODEL_PLACEHOLDER_M9' } }, + })).toEqual({ MODEL_PLACEHOLDER_M9: 'good' }) + expect(extractAntigravityModelMap(null)).toEqual({}) + }) + + it('extracts generator metadata from wrapped and unwrapped RPC responses', () => { + const metadata = [{ + chatModel: { + model: 'gemini-3-pro', + usage: { + model: 'gemini-3-pro', + inputTokens: '10', + outputTokens: '4', + apiProvider: 'google', + }, + }, + }] + + expect(extractAntigravityGeneratorMetadata({ response: { generatorMetadata: metadata } })).toEqual(metadata) + expect(extractAntigravityGeneratorMetadata({ generatorMetadata: metadata })).toEqual(metadata) + expect(extractAntigravityGeneratorMetadata({ response: { generatorMetadata: null } })).toEqual([]) + expect(extractAntigravityGeneratorMetadata(null)).toEqual([]) + }) +})