Reverse-engineered the UnifiedStateSyncUpdate protocol: - initial_state field is bytes (not string), contains serialized Topic proto - Map key for OAuth is 'oauthTokenInfoSentinelKey' - Row.value is base64-encoded OAuthTokenInfo protobuf - OAuthTokenInfo includes access_token, token_type, expiry (Timestamp) - Set far-future expiry (2099) to prevent token expiry errors Also fixed: - PushUnifiedStateSyncUpdate returns proper empty proto response - Stream keep-alive avoids sending empty envelopes (LS rejects nil updates) - uss-enterprisePreferences topic handled (empty initial state)
351 lines
13 KiB
Markdown
351 lines
13 KiB
Markdown
# Extension Server Protocol — Deep Analysis
|
|
|
|
Source: `strings` analysis of `/usr/share/antigravity/resources/app/extensions/antigravity/bin/language_server_linux_x64`
|
|
|
|
---
|
|
|
|
## Architecture Overview
|
|
|
|
```mermaid
|
|
graph TD
|
|
subgraph "Antigravity App (Electron)"
|
|
EXT["Extension (TypeScript)"]
|
|
EXTSRV["Extension Server<br/>(ConnectRPC, HTTP/1.1)"]
|
|
end
|
|
|
|
subgraph "Language Server (Go binary)"
|
|
LS["LanguageServerService<br/>(API Server, ConnectRPC)"]
|
|
ESC["ExtensionServerClient<br/>(ConnectRPC client)"]
|
|
STATESYNC["UnifiedStateSyncClient"]
|
|
CAC["CodeAssistClient"]
|
|
end
|
|
|
|
subgraph "Google Cloud"
|
|
GOOGLE["daily-cloudcode-pa.googleapis.com"]
|
|
end
|
|
|
|
EXT -->|"starts"| EXTSRV
|
|
ESC -->|"HTTP/1.1<br/>application/connect+proto<br/>(ServerStream RPC)"| EXTSRV
|
|
LS -->|"HTTPS<br/>application/connect+proto"| GOOGLE
|
|
STATESYNC -->|"via ESC"| EXTSRV
|
|
CAC -->|"GetOAuthToken()"| STATESYNC
|
|
|
|
style EXTSRV fill:#f96,stroke:#333
|
|
style STATESYNC fill:#69f,stroke:#333
|
|
```
|
|
|
|
### Communication Paths
|
|
|
|
| From → To | Protocol | Content-Type | Direction |
|
|
| --------------------- | -------- | ------------------------------ | -------------------- |
|
|
| Proxy → LS API Server | HTTP/1.1 | `application/json` (JSON-RPC) | Unary |
|
|
| Proxy → LS API Server | HTTP/1.1 | `application/proto` (protobuf) | Unary |
|
|
| LS → Extension Server | HTTP/1.1 | `application/connect+proto` | **Server-Streaming** |
|
|
| LS → Google API | HTTPS/H2 | `application/connect+proto` | Streaming |
|
|
|
|
> [!IMPORTANT]
|
|
> **ALL ExtensionServerService methods use Server-Streaming Connect RPC**, even methods like `GetSecretValue` and `LanguageServerStarted` that are semantically unary.
|
|
> This means every response must use Connect streaming envelope framing.
|
|
|
|
---
|
|
|
|
## Connect Streaming Protocol
|
|
|
|
The LS uses `connect-go` v1 (`connectrpc.com/connect/v1`). All extension server calls use **ServerStream**, which requires:
|
|
|
|
### Request Format (LS → Stub)
|
|
|
|
```
|
|
POST /exa.extension_server_pb.ExtensionServerService/{Method} HTTP/1.1
|
|
Content-Type: application/connect+proto
|
|
Connect-Protocol-Version: 1
|
|
```
|
|
|
|
Body: raw protobuf (NOT envelope-framed for client→server in ServerStream)
|
|
|
|
### Response Format (Stub → LS)
|
|
|
|
```
|
|
HTTP/1.1 200 OK
|
|
Content-Type: application/connect+proto
|
|
```
|
|
|
|
Body: sequence of **envelope frames**:
|
|
|
|
```
|
|
┌─────────┬──────────────────┬───────────────┐
|
|
│ Flags │ Message Length │ Message Data │
|
|
│ (1 byte)│ (4 bytes, BE) │ (N bytes) │
|
|
├─────────┼──────────────────┼───────────────┤
|
|
│ 0x00 │ protobuf length │ protobuf data │ ← data message (optional, 0 or more)
|
|
│ 0x02 │ trailer length │ JSON trailer │ ← end-of-stream (required, exactly 1)
|
|
└─────────┴──────────────────┴───────────────┘
|
|
```
|
|
|
|
### Examples
|
|
|
|
**Empty success** (most methods):
|
|
|
|
```
|
|
\x02 \x00\x00\x00\x02 {}
|
|
```
|
|
|
|
= flag(0x02) + length(2) + trailer("{}")
|
|
|
|
**Success with data** (e.g., GetSecretValue):
|
|
|
|
```
|
|
\x00 \x00\x00\x00\x0e \x0a\x0c ya29.a0A... ← data: protobuf response
|
|
\x02 \x00\x00\x00\x02 {} ← end-of-stream trailer
|
|
```
|
|
|
|
**Error response** (e.g., not found):
|
|
|
|
```
|
|
\x02 \x00\x00\x00\x22 {"error":{"code":"not_found"}}
|
|
```
|
|
|
|
> [!WARNING]
|
|
> Sending `Content-Length: 0` with `Content-Type: application/connect+proto` causes `unexpected EOF` — the client expects at least the end-of-stream envelope.
|
|
|
|
---
|
|
|
|
## Extension Server Service — Method Reference
|
|
|
|
### All Methods Are ServerStream
|
|
|
|
From binary analysis, every method on `ExtensionServerService` is wrapped as:
|
|
|
|
```go
|
|
*connect.ServerStreamForClient[...Response]
|
|
```
|
|
|
|
This includes ALL of these methods:
|
|
|
|
| Method | Request Proto | Response Proto | Purpose |
|
|
| ---------------------------------- | ----------------------------------------- | ------------------------------------ | ------------------ |
|
|
| `LanguageServerStarted` | `LanguageServerStartedRequest` | `LanguageServerStartedResponse` | Init notification |
|
|
| `GetSecretValue` | `GetSecretValueRequest{Key}` | `GetSecretValueResponse{Value}` | Secret store read |
|
|
| `StoreSecretValue` | `StoreSecretValueRequest{Key,Value}` | `StoreSecretValueResponse` | Secret store write |
|
|
| `SubscribeToUnifiedStateSyncTopic` | `SubscribeToUnifiedStateSyncTopicRequest` | `UnifiedStateSyncUpdate` (stream) | Real-time state |
|
|
| `PushUnifiedStateSyncUpdate` | `PushUnifiedStateSyncUpdateRequest` | `PushUnifiedStateSyncUpdateResponse` | Push state updates |
|
|
| `GetChromeDevtoolsMcpUrl` | `GetChromeDevtoolsMcpUrlRequest` | `GetChromeDevtoolsMcpUrlResponse` | Chrome DevTools |
|
|
| `FetchMCPAuthToken` | `FetchMCPAuthTokenRequest` | `FetchMCPAuthTokenResponse` | MCP auth |
|
|
| `AddAnnotation` | `AddAnnotationRequest` | `AddAnnotationResponse` | IDE annotation |
|
|
| `OpenFilePointer` | `OpenFilePointerRequest` | `OpenFilePointerResponse` | Open file in IDE |
|
|
| `ExecuteCommand` | `ExecuteCommandRequest` | `TerminalShellCommandStreamChunk` | Run terminal cmd |
|
|
| ... | ... | ... | 53+ total methods |
|
|
|
|
---
|
|
|
|
## OAuth Token Flow
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant LS as Language Server
|
|
participant ESC as ExtensionServerClient
|
|
participant STUB as Stub Extension Server
|
|
participant SYNC as UnifiedStateSyncClient
|
|
|
|
Note over LS: Startup
|
|
LS->>ESC: LanguageServerStarted()
|
|
ESC->>STUB: POST .../LanguageServerStarted
|
|
STUB-->>ESC: 200 OK (empty envelope)
|
|
|
|
Note over LS: OAuth Token Request
|
|
LS->>SYNC: GetOAuthTokenInfo()
|
|
SYNC->>ESC: GetSecretValue({key: "oauth_token"})
|
|
ESC->>STUB: POST .../GetSecretValue
|
|
STUB-->>ESC: 200 OK (envelope with {value: "ya29.a0..."})
|
|
ESC-->>SYNC: {value: "ya29.a0..."}
|
|
SYNC-->>LS: OAuthTokenInfo{access_token: "ya29.a0..."}
|
|
|
|
Note over LS: Using Token
|
|
LS->>LS: Set auth_token on API requests
|
|
LS->>LS: GetUserStatus, LoadCodeAssist, etc.
|
|
```
|
|
|
|
### Key Insight: How the LS Gets OAuth Tokens
|
|
|
|
The call chain at `server.go:558` (`Failed to get OAuth token`):
|
|
|
|
1. **`CodeAssistClient.GetOAuthToken()`** — entry point for getting OAuth
|
|
2. → **`UnifiedStateSyncClient.GetOAuthTokenInfo()`** — state sync layer
|
|
3. → **`ExtensionServerClient.GetSecretValue(key)`** — calls extension server
|
|
4. → **Our stub** must respond with the token value
|
|
|
|
### Proto Structures
|
|
|
|
**GetSecretValueRequest** (from binary):
|
|
|
|
```protobuf
|
|
message GetSecretValueRequest {
|
|
string key = 1; // protobuf:"bytes,1,opt,name=key"
|
|
}
|
|
```
|
|
|
|
**GetSecretValueResponse** (from binary):
|
|
|
|
```protobuf
|
|
message GetSecretValueResponse {
|
|
string value = 1; // protobuf:"bytes,1,opt,name=value"
|
|
}
|
|
```
|
|
|
|
**OAuthTokenInfo** (from binary):
|
|
|
|
```protobuf
|
|
message OAuthTokenInfo {
|
|
string access_token = ?; // .GetAccessToken()
|
|
string expiry = ?; // .GetExpiry()
|
|
string refresh_token = ?; // .GetRefreshToken()
|
|
string token_type = ?; // .GetTokenType()
|
|
}
|
|
```
|
|
|
|
**SaveOAuthTokenInfoRequest** contains:
|
|
|
|
```protobuf
|
|
message SaveOAuthTokenInfoRequest {
|
|
OAuthTokenInfo token_info = 1; // protobuf:"bytes,1,opt,name=token_info"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## UnifiedStateSyncTopic
|
|
|
|
The state sync system is central to the LS's operation:
|
|
|
|
```mermaid
|
|
graph LR
|
|
LSS["LS State Sync"] -->|"Subscribe"| EXT["Extension Server"]
|
|
EXT -->|"Stream updates"| LSS
|
|
LSS -->|"Push"| EXT
|
|
|
|
LSS --> |"Reads"| OAuth["OAuth Token"]
|
|
LSS --> |"Reads"| Settings["User Settings"]
|
|
LSS --> |"Reads"| Models["Available Models"]
|
|
LSS --> |"Reads"| Trajectories["Trajectory Summaries"]
|
|
```
|
|
|
|
### What the LS reads via state sync:
|
|
|
|
From `statesync.(*UnifiedStateSyncClient)` methods:
|
|
|
|
- `GetOAuthTokenInfo()` — OAuth access token
|
|
- `GetSecureModeEnabled()` — security settings
|
|
- `GetBrowserToolsEnabled()` — browser tool access
|
|
- `GetBrowserAllowlist()` — allowed browser URLs
|
|
- `GetBrowserJsExecutionPolicy()` — JS execution policy
|
|
- `GetTerminalAutoExecutionPolicy()` — terminal auto-run policy
|
|
- `GetTerminalAllowedCommands()` — allowed terminal commands
|
|
- `GetTerminalDeniedCommands()` — denied terminal commands
|
|
- `GetGcpProjectID()` — GCP project binding
|
|
- `GetAllowAgentAccessNonWorkspaceFiles()` — file access policy
|
|
- `GetAllowCascadeAccessGitignoreFiles()` — gitignore policy
|
|
- `GetAutoContinueOnMaxGeneratorInvocations()` — auto-continue
|
|
- `GetDisableCascadeAutoFixLints()` — lint autofix
|
|
- `GetTabGitignoreAccess()` — tab-level gitignore
|
|
|
|
### SubscribeToUnifiedStateSyncTopic
|
|
|
|
This is a **long-lived server stream**. The LS subscribes at startup and expects to receive `UnifiedStateSyncUpdate` messages over time. If the stub closes the connection, the LS will reconnect.
|
|
|
|
For a minimal stub, we can:
|
|
|
|
1. Accept the subscription request
|
|
2. Send an end-of-stream trailer immediately (empty data)
|
|
3. Close the connection
|
|
|
|
The LS will poll/retry, but this won't break functionality — it just means settings won't be dynamically updated (which is fine for headless mode).
|
|
|
|
---
|
|
|
|
## Proxy → LS Communication
|
|
|
|
The proxy communicates with the LS API server (`LanguageServerService`) differently:
|
|
|
|
| Method | Content-Type | Protocol |
|
|
| --------------------------------- | ------------------- | ------------- |
|
|
| `call_json()` (most methods) | `application/json` | Unary Connect |
|
|
| `call_proto()` (protobuf methods) | `application/proto` | Unary Connect |
|
|
|
|
Both use `Connect-Protocol-Version: 1` header.
|
|
|
|
> [!NOTE]
|
|
> The LS API server accepts unary Connect calls with `application/json` or `application/proto`.
|
|
> This is different from the Extension Server which expects streaming `application/connect+proto`.
|
|
|
|
---
|
|
|
|
## What the Headless Stub Must Implement
|
|
|
|
### Critical (blocks all requests):
|
|
|
|
1. **GetSecretValue** — Must return the OAuth token when requested
|
|
- Request body contains `key` (field 1, string)
|
|
- Response must contain `value` (field 1, string) = the OAuth token
|
|
- Must use Connect streaming envelope framing
|
|
|
|
2. **LanguageServerStarted** — Must acknowledge startup
|
|
- Empty success response (just end-of-stream trailer)
|
|
|
|
### Important (blocks some features):
|
|
|
|
3. **SubscribeToUnifiedStateSyncTopic** — Settings subscription
|
|
- Can return empty end-of-stream (no data messages)
|
|
- LS will retry but won't crash
|
|
|
|
4. **PushUnifiedStateSyncUpdate** — State push
|
|
- Can return empty success
|
|
|
|
### Nice-to-have (non-blocking):
|
|
|
|
5. All other methods — return empty success
|
|
- `GetChromeDevtoolsMcpUrl`, `ShowAnnotation`, `OpenFilePointer`, etc.
|
|
|
|
---
|
|
|
|
## Current Stub Issues (from latest debug log)
|
|
|
|
### Issue 1: "key not found"
|
|
|
|
```
|
|
E0215 20:05:56.311541 server.go:558] Failed to get OAuth token: key not found
|
|
```
|
|
|
|
The `GetSecretValue` response doesn't match what the LS expects. The LS calls `GetSecretValue` with a specific key, but our stub ignores the key and always returns the token. The "key not found" error suggests the LS's state sync layer caches by key and doesn't find the expected entry.
|
|
|
|
**Root cause**: The LS doesn't just call `GetSecretValue` — it goes through the `UnifiedStateSyncClient` which uses `GetRow(key)`. The state sync is a key-value store. The LS looks up a specific key in state sync, and the state sync client calls `GetSecretValue` on the extension server. Since our stub returns an empty protobuf for everything except `GetSecretValue`, the state sync's initial `SubscribeToUnifiedStateSyncTopic` gets no data, and subsequent `GetRow()` calls return "key not found".
|
|
|
|
### Issue 2: "unknown model key MODEL_PLACEHOLDER_M18"
|
|
|
|
```
|
|
E0215 20:05:56.358443 interceptor.go:74] SendUserCascadeMessage: unknown model key MODEL_PLACEHOLDER_M18
|
|
```
|
|
|
|
The model configuration isn't loaded because `Cache(loadCodeAssistResponse)` failed. This cache depends on `userInfo` which depends on the OAuth token. Fix the token flow and this should resolve.
|
|
|
|
### Issue 3: "mkdir permission denied"
|
|
|
|
```
|
|
E0215 20:05:56.311614 log.go:380] Failed to create artifacts directory...mkdir /tmp/antigravity-standalone/.gemini/antigravity-standalone/brain/.../: permission denied
|
|
```
|
|
|
|
The LS tries to create directories under the `gemini_dir`. This is non-fatal but noisy.
|
|
|
|
---
|
|
|
|
## Recommended Fix Strategy
|
|
|
|
The current approach of parsing individual methods won't scale — ALL 53+ methods are ServerStream and need envelope framing.
|
|
|
|
**Better approach**: Instead of understanding every method, ensure:
|
|
|
|
1. **Every response** uses Connect streaming envelope framing (`0x02 + len + {}` minimum)
|
|
2. **GetSecretValue** returns the token in a data envelope before the end-of-stream
|
|
3. **Content-Type** is always `application/connect+proto`
|
|
4. **Connection: close** to avoid HTTP keep-alive issues
|
|
5. Create the `gemini_dir` with proper permissions before spawning the LS
|