mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-16 19:44:14 +00:00
Merge pull request #324 from ozymandiashh/fix/antigravity-windows-discovery
Fix Antigravity Windows discovery
This commit is contained in:
commit
c56f7ea7db
4 changed files with 258 additions and 48 deletions
|
|
@ -32,6 +32,10 @@
|
|||
`<server>_<tool>` names, which the shared MCP pipeline did not recognize.
|
||||
The provider now normalizes these to the canonical `mcp__<server>__<tool>`
|
||||
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`).
|
||||
|
|
|
|||
|
|
@ -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 `<cascadeId>:<responseId>` (`antigravity.ts:308`).
|
||||
Per `<cascadeId>:<responseId>`.
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
|
|
@ -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<string, { model?: string }>
|
||||
response?: {
|
||||
models?: Record<string, { model?: string }>
|
||||
}
|
||||
}
|
||||
|
||||
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<string> {
|
||||
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<AntigravityCache> {
|
||||
if (memCache) return memCache
|
||||
try {
|
||||
|
|
@ -124,27 +207,27 @@ async function flushCache(liveCascadeIds?: Set<string>): Promise<void> {
|
|||
} catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
async function readProcessCommandLines(): Promise<string[]> {
|
||||
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<ServerInfo | null> {
|
||||
if (cachedServer !== undefined) return cachedServer
|
||||
try {
|
||||
const output = await new Promise<string>((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<string, unkn
|
|||
|
||||
async function getModelMap(server: ServerInfo): Promise<ModelMap> {
|
||||
if (cachedModelMap) return cachedModelMap
|
||||
const map: ModelMap = {}
|
||||
try {
|
||||
const resp = await rpc(server, 'GetAvailableModels') as {
|
||||
response?: { models?: Record<string, { model?: string }> }
|
||||
}
|
||||
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<string>): 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) {
|
||||
|
|
|
|||
123
tests/providers/antigravity.test.ts
Normal file
123
tests/providers/antigravity.test.ts
Normal file
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue