feat: implement headless LS authentication via state sync
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)
This commit is contained in:
21
GEMINI.md
21
GEMINI.md
@@ -5,10 +5,11 @@ OpenAI-compatible proxy that intercepts and relays requests to Google's Antigrav
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# First-time setup (creates user + iptables for MITM)
|
||||
sudo ./scripts/mitm-redirect.sh install
|
||||
# Headless mode (no running Antigravity app needed)
|
||||
RUST_LOG=info ./target/release/antigravity-proxy --headless
|
||||
|
||||
# Start as daemon (builds if needed)
|
||||
# Classic mode (requires running Antigravity + sudo setup for MITM)
|
||||
sudo ./scripts/mitm-redirect.sh install
|
||||
proxyctl start
|
||||
|
||||
# Or run directly
|
||||
@@ -204,7 +205,18 @@ Falls back to hardcoded values if the app isn't installed. No manual updates nee
|
||||
|
||||
## Standalone LS
|
||||
|
||||
By default, the proxy spawns its own Language Server instance for full isolation:
|
||||
By default, the proxy spawns its own Language Server instance for full isolation.
|
||||
|
||||
### Headless Mode (`--headless`)
|
||||
|
||||
Fully independent — no running Antigravity app, no sudo, no iptables:
|
||||
|
||||
1. Generates its own CSRF token (random UUID)
|
||||
2. Passes `-standalone=true` and `-extension_server_port=0` to the LS binary
|
||||
3. Uses `HTTPS_PROXY` for MITM (no iptables required)
|
||||
4. Only needs the LS binary installed at the standard path
|
||||
|
||||
### Classic Mode (default)
|
||||
|
||||
1. Discovers the main LS config (`extension_server_port`, `csrf_token`) from the running Antigravity app
|
||||
2. Spawns a standalone LS binary on a random port
|
||||
@@ -270,6 +282,7 @@ sudo ./scripts/mitm-redirect.sh uninstall
|
||||
|
||||
### CLI Flags
|
||||
|
||||
- `--headless`: Fully standalone — no running Antigravity app required
|
||||
- `--no-mitm`: Disable MITM proxy entirely
|
||||
- `--no-standalone`: Attach to existing LS instead of spawning standalone
|
||||
- `--mitm-port <PORT>`: Override MITM proxy port (default: auto-assign)
|
||||
|
||||
350
docs/extension-server-analysis.md
Normal file
350
docs/extension-server-analysis.md
Normal file
@@ -0,0 +1,350 @@
|
||||
# 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
|
||||
@@ -1,16 +1,33 @@
|
||||
# Standalone LS for Proxy Isolation
|
||||
|
||||
## Status: ✅ FULLY IMPLEMENTED (incl. MITM interception)
|
||||
## Status: ✅ FULLY IMPLEMENTED (incl. headless mode + MITM)
|
||||
|
||||
The standalone LS is the default mode. Disable with `--no-standalone`.
|
||||
All cascade types (sync, streaming, multi-turn) and all endpoints work.
|
||||
MITM interception captures real token usage from Google's API.
|
||||
Two modes available:
|
||||
|
||||
## Implementation
|
||||
- **Normal standalone** (default) — steals config from running Antigravity, optional UID isolation
|
||||
- **Headless** (`--headless`) — fully independent, no running Antigravity required
|
||||
|
||||
**Module:** `src/standalone.rs`
|
||||
## Headless Mode
|
||||
|
||||
The proxy spawns a standalone LS as a child process:
|
||||
Pass `--headless` to the proxy. This:
|
||||
|
||||
1. Generates its own CSRF token (random UUID)
|
||||
2. Passes `-extension_server_port=0` to the LS (disables extension server callbacks)
|
||||
3. Passes `-standalone=true` to the LS binary (built-in standalone flag)
|
||||
4. Uses `HTTPS_PROXY` env var for MITM (no iptables/sudo required)
|
||||
5. No `/proc` scanning, no dependency on running Antigravity
|
||||
|
||||
```bash
|
||||
# Headless (no Antigravity needed)
|
||||
RUST_LOG=info ./target/release/antigravity-proxy --headless
|
||||
|
||||
# With MITM disabled
|
||||
./target/release/antigravity-proxy --headless --no-mitm
|
||||
```
|
||||
|
||||
## Normal Standalone Mode
|
||||
|
||||
The default mode (disable with `--no-standalone`):
|
||||
|
||||
1. Discovers `extension_server_port` and `csrf_token` from the real LS (via `/proc/PID/cmdline`)
|
||||
2. Picks a random free port
|
||||
@@ -31,26 +48,19 @@ When `scripts/mitm-redirect.sh install` has been run:
|
||||
5. A combined CA bundle (system CAs + MITM CA) is written to `/tmp/antigravity-mitm-combined-ca.pem`
|
||||
6. Only the standalone LS traffic is intercepted — no impact on other software
|
||||
|
||||
## Usage
|
||||
## LS Binary Flags (Reference)
|
||||
|
||||
```bash
|
||||
# Setup (one-time, requires sudo)
|
||||
sudo ./scripts/mitm-redirect.sh install
|
||||
From `language_server_linux_x64 --help`:
|
||||
|
||||
# Run
|
||||
RUST_LOG=info ./target/release/antigravity-proxy
|
||||
|
||||
# Check intercepted usage
|
||||
curl -s http://localhost:8741/v1/usage | jq .
|
||||
```
|
||||
|
||||
## Root Cause of Original Failure
|
||||
|
||||
The bash script (`scripts/standalone-ls.sh`) used `MODEL_PLACEHOLDER_M3` — an
|
||||
unassigned/invalid model enum. The LS silently drops cascades with unknown models.
|
||||
|
||||
**Fix:** Use correct model enums (M18=Flash, M26=Opus4.6) via the proxy's
|
||||
byte-exact protobuf encoder.
|
||||
| Flag | Default | Description |
|
||||
| ------------------------ | ------- | ------------------------------------- |
|
||||
| `-standalone` | `false` | Whether to run in standalone mode |
|
||||
| `-extension_server_port` | `0` | Extension server port. If 0, not used |
|
||||
| `-csrf_token` | `""` | CSRF token for RPC auth |
|
||||
| `-server_port` | `42100` | Port for LS ↔ extension |
|
||||
| `-enable_lsp` | `false` | Enable LSP protocol |
|
||||
| `-cloud_code_endpoint` | `""` | CCPA API URL |
|
||||
| `-parent_pipe_path` | `""` | Monitors parent process liveness |
|
||||
|
||||
## Key Technical Details
|
||||
|
||||
@@ -58,7 +68,6 @@ byte-exact protobuf encoder.
|
||||
- Model IDs: M18=Flash, M8=Pro-High, M7=Pro-Low, M26=Opus4.6, M12=Opus4.5
|
||||
- LS binary: `/usr/share/antigravity/resources/app/extensions/antigravity/bin/language_server_linux_x64`
|
||||
- API endpoint: `daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse`
|
||||
- SSE response format: `{"response": {"usageMetadata": {"promptTokenCount", "candidatesTokenCount", "thoughtsTokenCount"}, "modelVersion": "..."}}`
|
||||
|
||||
## Test Results (2026-02-15)
|
||||
|
||||
|
||||
28
src/main.rs
28
src/main.rs
@@ -49,6 +49,11 @@ struct Cli {
|
||||
/// Disable standalone LS — attach to the real running LS instead
|
||||
#[arg(long)]
|
||||
no_standalone: bool,
|
||||
|
||||
/// Headless mode — no running Antigravity app required.
|
||||
/// Generates its own CSRF, disables extension server, uses HTTPS_PROXY for MITM.
|
||||
#[arg(long)]
|
||||
headless: bool,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -104,13 +109,20 @@ async fn main() {
|
||||
};
|
||||
|
||||
// ── Step 2: Backend discovery (or standalone LS spawn) ─────────────────────
|
||||
let standalone_ls = if !cli.no_standalone {
|
||||
// Standalone mode: discover main LS config, spawn our own
|
||||
let main_config = match standalone::discover_main_ls_config() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("Fatal: {e}");
|
||||
std::process::exit(1);
|
||||
// --headless implies standalone mode
|
||||
let standalone_ls = if cli.headless || !cli.no_standalone {
|
||||
// Get LS config: headless generates its own, normal steals from running LS
|
||||
let main_config = if cli.headless {
|
||||
info!("Headless mode: generating self-contained config");
|
||||
standalone::generate_standalone_config()
|
||||
} else {
|
||||
match standalone::discover_main_ls_config() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("Fatal: {e}");
|
||||
eprintln!("Hint: start Antigravity first, or use --headless for full independence");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Build MITM config if MITM is enabled
|
||||
@@ -127,7 +139,7 @@ async fn main() {
|
||||
None
|
||||
};
|
||||
|
||||
let ls = match standalone::StandaloneLS::spawn(&main_config, mitm_cfg.as_ref()) {
|
||||
let mut ls = match standalone::StandaloneLS::spawn(&main_config, mitm_cfg.as_ref(), cli.headless) {
|
||||
Ok(ls) => ls,
|
||||
Err(e) => {
|
||||
eprintln!("Fatal: failed to spawn standalone LS: {e}");
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! Standalone Language Server — spawn and lifecycle management.
|
||||
//!
|
||||
//! Launches an isolated LS instance as a child process that the proxy fully owns.
|
||||
//! The standalone LS shares auth via the main extension server but has its own
|
||||
//! HTTPS port, data directory, and cascade space. This means the real LS (the
|
||||
//! one powering the user's coding session) is never touched.
|
||||
//! In **headless** mode, the LS runs completely independently — no running
|
||||
//! Antigravity app required. Extension server is disabled (`port=0`), CSRF is
|
||||
//! self-generated, and MITM uses `HTTPS_PROXY` instead of iptables.
|
||||
|
||||
use crate::constants;
|
||||
use crate::proto;
|
||||
@@ -12,6 +12,7 @@ use std::net::TcpListener;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use tokio::time::{sleep, Duration};
|
||||
use tracing::{debug, info};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Default path to the LS binary.
|
||||
const LS_BINARY_PATH: &str =
|
||||
@@ -39,12 +40,28 @@ pub struct StandaloneLS {
|
||||
pub csrf: String,
|
||||
}
|
||||
|
||||
/// Config needed from the real (main) LS to bootstrap the standalone one.
|
||||
/// Config needed to bootstrap the standalone LS.
|
||||
///
|
||||
/// In normal mode, discovered from the running main LS.
|
||||
/// In headless mode, generated entirely by the proxy.
|
||||
pub struct MainLSConfig {
|
||||
pub extension_server_port: String,
|
||||
pub csrf: String,
|
||||
}
|
||||
|
||||
/// Generate a fully self-contained config for headless mode.
|
||||
///
|
||||
/// No running Antigravity instance needed — extension server is disabled
|
||||
/// and CSRF is a random UUID.
|
||||
pub fn generate_standalone_config() -> MainLSConfig {
|
||||
let csrf = Uuid::new_v4().to_string();
|
||||
info!(csrf_len = csrf.len(), "Generated standalone config (headless)");
|
||||
MainLSConfig {
|
||||
extension_server_port: "0".to_string(), // disables extension server
|
||||
csrf,
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional MITM proxy config for the standalone LS.
|
||||
pub struct StandaloneMitmConfig {
|
||||
pub proxy_addr: String, // e.g. "http://127.0.0.1:8742"
|
||||
@@ -62,6 +79,7 @@ impl StandaloneLS {
|
||||
pub fn spawn(
|
||||
main_config: &MainLSConfig,
|
||||
mitm_config: Option<&StandaloneMitmConfig>,
|
||||
headless: bool,
|
||||
) -> Result<Self, String> {
|
||||
// Kill any orphaned LS processes from previous runs
|
||||
cleanup_orphaned_ls();
|
||||
@@ -93,11 +111,79 @@ impl StandaloneLS {
|
||||
let _ = std::fs::set_permissions(&gemini_dir, std::fs::Permissions::from_mode(0o1777));
|
||||
}
|
||||
|
||||
// LS args — mirrors standalone-ls.sh but with correct params
|
||||
// In headless mode, spawn a stub TCP listener to serve as the extension server.
|
||||
// The LS connects to this port and calls LanguageServerStarted — without it,
|
||||
// the LS never fully initializes and won't accept connections on its server_port.
|
||||
let _stub_listener = if headless {
|
||||
let stub_port: u16 = main_config
|
||||
.extension_server_port
|
||||
.parse()
|
||||
.unwrap_or(0);
|
||||
if stub_port == 0 {
|
||||
// Create a real listener so the LS can connect
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.map_err(|e| format!("Failed to bind stub extension server: {e}"))?;
|
||||
let actual_port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| format!("Failed to get stub port: {e}"))?
|
||||
.port();
|
||||
info!(port = actual_port, "Stub extension server listening (headless)");
|
||||
// Read OAuth token for serving via stub (same sources as backend)
|
||||
let oauth_token = std::env::var("ANTIGRAVITY_OAUTH_TOKEN")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| {
|
||||
let home = std::env::var("HOME").unwrap_or_default();
|
||||
let path = format!("{home}/.config/antigravity-proxy-token");
|
||||
std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let oauth_arc = std::sync::Arc::new(oauth_token);
|
||||
// Spawn a thread to accept connections (just hold them open)
|
||||
let listener_clone = listener
|
||||
.try_clone()
|
||||
.map_err(|e| format!("Failed to clone stub listener: {e}"))?;
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener_clone.incoming() {
|
||||
match stream {
|
||||
Ok(conn) => {
|
||||
let token = std::sync::Arc::clone(&oauth_arc);
|
||||
// Handle each connection in its own thread
|
||||
std::thread::spawn(move || {
|
||||
stub_handle_connection(conn, &token);
|
||||
});
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
// Update the extension_server_port to the stub's port
|
||||
// (we need to use this in args below)
|
||||
Some((listener, actual_port))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Determine the actual extension_server_port to use
|
||||
let ext_port = if let Some((_, stub_port)) = &_stub_listener {
|
||||
stub_port.to_string()
|
||||
} else {
|
||||
main_config.extension_server_port.clone()
|
||||
};
|
||||
|
||||
// LS args — NO -standalone flag (it disables TCP listeners entirely)
|
||||
let args = vec![
|
||||
"-enable_lsp".to_string(),
|
||||
"-random_port".to_string(),
|
||||
"-lsp_port=0".to_string(),
|
||||
"-extension_server_port".to_string(),
|
||||
main_config.extension_server_port.clone(),
|
||||
ext_port,
|
||||
"-csrf_token".to_string(),
|
||||
main_config.csrf.clone(),
|
||||
"-server_port".to_string(),
|
||||
@@ -151,25 +237,24 @@ impl StandaloneLS {
|
||||
env_vars.push(("SSL_CERT_FILE".into(), combined_ca_path));
|
||||
env_vars.push(("SSL_CERT_DIR".into(), "/dev/null".into()));
|
||||
env_vars.push(("NODE_EXTRA_CA_CERTS".into(), mitm.ca_cert_path.clone()));
|
||||
// Only set HTTPS_PROXY when iptables UID isolation is NOT available.
|
||||
// Only set HTTPS_PROXY when iptables UID isolation is NOT available
|
||||
// OR when running in headless mode (no sudo at all).
|
||||
// With iptables, all outbound traffic is transparently redirected at the
|
||||
// kernel level — setting HTTPS_PROXY on top causes double-proxying
|
||||
// (profile picture fetches, etc. break with "lookup http" errors).
|
||||
if !has_ls_user() {
|
||||
// kernel level — setting HTTPS_PROXY on top causes double-proxying.
|
||||
if headless || !has_ls_user() {
|
||||
env_vars.push(("HTTPS_PROXY".into(), format!("http://{}", mitm.proxy_addr)));
|
||||
env_vars.push(("HTTP_PROXY".into(), format!("http://{}", mitm.proxy_addr)));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if 'antigravity-ls' user exists for UID-scoped iptables isolation
|
||||
let use_sudo = has_ls_user();
|
||||
// In headless mode, never use sudo — run as current user
|
||||
// In normal mode, use sudo if 'antigravity-ls' user exists
|
||||
let use_sudo = !headless && has_ls_user();
|
||||
|
||||
let mut cmd = if use_sudo {
|
||||
info!("Using UID isolation: spawning LS as 'antigravity-ls' user");
|
||||
// Build: sudo -n -u antigravity-ls -- /usr/bin/env VAR=val ... LS_BINARY args...
|
||||
let mut c = Command::new("sudo");
|
||||
c.args(["-n", "-u", LS_USER, "--", "/usr/bin/env"]);
|
||||
// Pass env vars as key=value args to /usr/bin/env
|
||||
for (k, v) in &env_vars {
|
||||
c.arg(format!("{k}={v}"));
|
||||
}
|
||||
@@ -177,7 +262,7 @@ impl StandaloneLS {
|
||||
c.args(&args);
|
||||
c
|
||||
} else {
|
||||
debug!("No 'antigravity-ls' user found, spawning LS as current user");
|
||||
debug!("Spawning LS as current user");
|
||||
let mut c = Command::new(LS_BINARY_PATH);
|
||||
c.args(&args);
|
||||
for (k, v) in &env_vars {
|
||||
@@ -186,9 +271,12 @@ impl StandaloneLS {
|
||||
c
|
||||
};
|
||||
|
||||
// Capture stderr for debugging — logs to /tmp so we can diagnose LS failures
|
||||
let stderr_file = std::fs::File::create("/tmp/antigravity-ls-debug.log")
|
||||
.map_err(|e| format!("Failed to create LS debug log: {e}"))?;
|
||||
cmd.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
.stderr(Stdio::from(stderr_file));
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
@@ -199,7 +287,7 @@ impl StandaloneLS {
|
||||
stdin
|
||||
.write_all(&metadata)
|
||||
.map_err(|e| format!("Failed to write init metadata to stdin: {e}"))?;
|
||||
// stdin drops here → EOF
|
||||
// stdin drops here → EOF (LS handles this fine in non-standalone mode)
|
||||
}
|
||||
|
||||
info!(pid = child.id(), port, "Standalone LS spawned");
|
||||
@@ -232,12 +320,25 @@ impl StandaloneLS {
|
||||
/// Wait for the standalone LS to be ready (accepting TCP connections).
|
||||
///
|
||||
/// Retries up to `max_attempts` times with a 1-second delay between each.
|
||||
pub async fn wait_ready(&self, max_attempts: u32) -> Result<(), String> {
|
||||
pub async fn wait_ready(&mut self, max_attempts: u32) -> Result<(), String> {
|
||||
info!(port = self.port, "Waiting for standalone LS to be ready...");
|
||||
|
||||
for attempt in 1..=max_attempts {
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
|
||||
// Check if the process is still alive
|
||||
match self.child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
return Err(format!(
|
||||
"Standalone LS exited prematurely with status: {status}"
|
||||
));
|
||||
}
|
||||
Ok(None) => {} // still running
|
||||
Err(e) => {
|
||||
return Err(format!("Failed to check LS process status: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
// Simple TCP connect check — if the LS is listening, it's ready
|
||||
match tokio::net::TcpStream::connect(format!("127.0.0.1:{}", self.port)).await {
|
||||
Ok(_) => {
|
||||
@@ -541,6 +642,390 @@ fn cleanup_orphaned_ls() {
|
||||
info!("Orphaned LS processes cleaned up");
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a single connection from the LS to the stub extension server.
|
||||
///
|
||||
/// The LS uses Connect RPC (HTTP/1.1, ServerStream) to call ExtensionServerService methods.
|
||||
/// ALL methods are ServerStream — responses use Connect streaming envelope framing:
|
||||
/// [0x00 | len(4) | protobuf_data]... (0+ data messages)
|
||||
/// [0x02 | len(4) | json_trailer] (exactly 1 end-of-stream)
|
||||
///
|
||||
/// IMPORTANT: `SubscribeToUnifiedStateSyncTopic` is a long-lived stream.
|
||||
/// If we immediately close it, the LS reconnects in a tight loop and never
|
||||
/// proceeds to fetch OAuth tokens. We keep subscription connections OPEN.
|
||||
fn stub_handle_connection(conn: std::net::TcpStream, oauth_token: &str) {
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
|
||||
let mut reader = BufReader::new(match conn.try_clone() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
});
|
||||
let mut writer = conn;
|
||||
|
||||
// Read the HTTP request line
|
||||
let mut request_line = String::new();
|
||||
match reader.read_line(&mut request_line) {
|
||||
Ok(0) | Err(_) => return,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Extract method path for logging
|
||||
let path = request_line
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.unwrap_or("/unknown")
|
||||
.to_string();
|
||||
|
||||
// Read headers
|
||||
let mut content_len: usize = 0;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).unwrap_or(0) == 0 {
|
||||
return;
|
||||
}
|
||||
if line.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
if line.to_lowercase().starts_with("content-length:") {
|
||||
content_len = line
|
||||
.split(':')
|
||||
.nth(1)
|
||||
.and_then(|v| v.trim().parse().ok())
|
||||
.unwrap_or(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Read body
|
||||
let mut body = Vec::new();
|
||||
if content_len > 0 {
|
||||
body.resize(content_len, 0u8);
|
||||
if Read::read_exact(&mut reader, &mut body).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Long-lived streams ──────────────────────────────────────────────
|
||||
// SubscribeToUnifiedStateSyncTopic must stay open — the LS subscribes
|
||||
// once and expects updates (OAuth, settings) delivered over this stream.
|
||||
// If we close immediately, the LS reconnects in a tight loop (~30/sec).
|
||||
if path.contains("SubscribeToUnifiedStateSyncTopic") {
|
||||
// Parse the request body to extract the topic name.
|
||||
// Connect envelope: [flag(1)] [len(4)] [proto(N)]
|
||||
let proto_body = if body.len() > 5 {
|
||||
&body[5..]
|
||||
} else {
|
||||
&body[..]
|
||||
};
|
||||
|
||||
// SubscribeToUnifiedStateSyncTopicRequest { string topic = 1; }
|
||||
let mut topic_name = String::new();
|
||||
let mut i = 0;
|
||||
while i < proto_body.len() {
|
||||
let tag_byte = proto_body[i];
|
||||
let field_num = tag_byte >> 3;
|
||||
let wire_type = tag_byte & 0x07;
|
||||
i += 1;
|
||||
if wire_type == 2 && i < proto_body.len() {
|
||||
let len = proto_body[i] as usize;
|
||||
i += 1;
|
||||
if i + len <= proto_body.len() {
|
||||
if field_num == 1 {
|
||||
topic_name = String::from_utf8_lossy(&proto_body[i..i+len]).to_string();
|
||||
}
|
||||
i += len;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("[stub-ext] STREAM → {path} topic={topic_name:?}");
|
||||
|
||||
// Protocol:
|
||||
// UnifiedStateSyncUpdate {
|
||||
// oneof UpdateType {
|
||||
// string initial_state = 1; // ← STRING, not a submessage!
|
||||
// AppliedUpdate applied_update = 2;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Flow:
|
||||
// 1. Send initial_state = "" (empty string = initial snapshot marker)
|
||||
// 2. For uss-oauth topic: send applied_update with OAuth token
|
||||
// 3. Hold stream open for future updates
|
||||
|
||||
// Helper: wrap protobuf bytes in a Connect data envelope
|
||||
let make_envelope = |proto: &[u8]| -> Vec<u8> {
|
||||
let mut env = Vec::with_capacity(5 + proto.len());
|
||||
env.push(0x00u8); // data flag
|
||||
env.extend_from_slice(&(proto.len() as u32).to_be_bytes());
|
||||
env.extend_from_slice(proto);
|
||||
env
|
||||
};
|
||||
|
||||
// Helper: write a chunk
|
||||
let send_chunk = |w: &mut std::net::TcpStream, data: &[u8]| -> bool {
|
||||
let hdr = format!("{:x}\r\n", data.len());
|
||||
w.write_all(hdr.as_bytes()).is_ok()
|
||||
&& w.write_all(data).is_ok()
|
||||
&& w.write_all(b"\r\n").is_ok()
|
||||
&& w.flush().is_ok()
|
||||
};
|
||||
|
||||
// --- Message 1: initial_state = Topic { data: { "authToken": Row { value: token, e_tag: 1 } } } ---
|
||||
// Topic { map<string, Row> data = 1; }
|
||||
// Row { string value = 1; int64 e_tag = 2; }
|
||||
// Map entry: { string key = 1, Row value = 2 }
|
||||
let mut initial_state_bytes = Vec::new();
|
||||
|
||||
if topic_name == "uss-oauth" && !oauth_token.is_empty() {
|
||||
// Row.value is base64-encoded protobuf OAuthTokenInfo:
|
||||
// OAuthTokenInfo {
|
||||
// string access_token = 1;
|
||||
// string token_type = 2;
|
||||
// string refresh_token = 3;
|
||||
// google.protobuf.Timestamp expiry = 4;
|
||||
// }
|
||||
let mut oauth_proto = Vec::new();
|
||||
// field 1 (access_token), LEN
|
||||
oauth_proto.push(0x0A);
|
||||
encode_varint(&mut oauth_proto, oauth_token.len() as u64);
|
||||
oauth_proto.extend_from_slice(oauth_token.as_bytes());
|
||||
// field 2 (token_type), LEN
|
||||
let token_type = b"Bearer";
|
||||
oauth_proto.push(0x12);
|
||||
encode_varint(&mut oauth_proto, token_type.len() as u64);
|
||||
oauth_proto.extend_from_slice(token_type);
|
||||
// field 4 (expiry) = Timestamp { seconds = 4_102_444_800 } (year 2099-12-31)
|
||||
// Timestamp proto: field 1 (seconds) varint
|
||||
let mut ts_proto = Vec::new();
|
||||
ts_proto.push(0x08); // field 1 (seconds), varint
|
||||
encode_varint(&mut ts_proto, 4_102_444_800u64);
|
||||
oauth_proto.push(0x22); // field 4 (expiry), LEN
|
||||
encode_varint(&mut oauth_proto, ts_proto.len() as u64);
|
||||
oauth_proto.extend_from_slice(&ts_proto);
|
||||
|
||||
use base64::Engine;
|
||||
let b64_value = base64::engine::general_purpose::STANDARD.encode(&oauth_proto);
|
||||
|
||||
// Build Row { value = b64_value, e_tag = 1 }
|
||||
let mut row = Vec::new();
|
||||
row.push(0x0A); // field 1 (value), LEN
|
||||
encode_varint(&mut row, b64_value.len() as u64);
|
||||
row.extend_from_slice(b64_value.as_bytes());
|
||||
row.push(0x10); // field 2 (e_tag), varint
|
||||
row.push(0x01);
|
||||
|
||||
// Build map entry: { key = "oauthTokenInfoSentinelKey", value = row }
|
||||
let key_str = b"oauthTokenInfoSentinelKey";
|
||||
let mut map_entry = Vec::new();
|
||||
map_entry.push(0x0A); // field 1 (key), LEN
|
||||
encode_varint(&mut map_entry, key_str.len() as u64);
|
||||
map_entry.extend_from_slice(key_str);
|
||||
map_entry.push(0x12); // field 2 (value = Row), LEN
|
||||
encode_varint(&mut map_entry, row.len() as u64);
|
||||
map_entry.extend_from_slice(&row);
|
||||
|
||||
// Build Topic { data = [map_entry] }
|
||||
initial_state_bytes.push(0x0A); // field 1 (data map), LEN
|
||||
encode_varint(&mut initial_state_bytes, map_entry.len() as u64);
|
||||
initial_state_bytes.extend_from_slice(&map_entry);
|
||||
}
|
||||
|
||||
// Build UnifiedStateSyncUpdate { initial_state = initial_state_bytes }
|
||||
let mut initial_proto = Vec::new();
|
||||
initial_proto.push(0x0A); // field 1 (initial_state), LEN
|
||||
encode_varint(&mut initial_proto, initial_state_bytes.len() as u64);
|
||||
initial_proto.extend_from_slice(&initial_state_bytes);
|
||||
|
||||
let initial_env = make_envelope(&initial_proto);
|
||||
|
||||
let header = format!(
|
||||
"HTTP/1.1 200 OK\r\n\
|
||||
Content-Type: application/connect+proto\r\n\
|
||||
Transfer-Encoding: chunked\r\n\
|
||||
\r\n"
|
||||
);
|
||||
if writer.write_all(header.as_bytes()).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
if !send_chunk(&mut writer, &initial_env) {
|
||||
return;
|
||||
}
|
||||
eprintln!("[stub-ext] STREAM → sent initial_state ({} bytes)", initial_state_bytes.len());
|
||||
|
||||
// (applied_update removed — data is in initial_state)
|
||||
|
||||
// Hold the connection open — LS will reconnect if it drops.
|
||||
// Don't send empty envelopes (LS rejects nil update type).
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_secs(60));
|
||||
// Probe: try writing zero bytes to detect closed connection
|
||||
if writer.write_all(b"").is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ─── Short-lived methods (everything else) ───────────────────────────
|
||||
let is_noisy = path.contains("GetChromeDevtoolsMcpUrl")
|
||||
|| path.contains("FetchMCPAuthToken")
|
||||
|| path.contains("PushUnifiedStateSyncUpdate");
|
||||
if !is_noisy {
|
||||
eprintln!("[stub-ext] 200 OK → {path}");
|
||||
}
|
||||
|
||||
// Build Connect streaming response body with proper envelope framing.
|
||||
let mut envelope = Vec::new();
|
||||
|
||||
if path.contains("GetSecretValue") {
|
||||
// Parse request body to extract the key (protobuf: field 1 = key, string)
|
||||
let key = extract_proto_string(&body, 1).unwrap_or_default();
|
||||
eprintln!("[stub-ext] ← GetSecretValue key={key:?}");
|
||||
|
||||
if !oauth_token.is_empty() {
|
||||
// Build protobuf: GetSecretValueResponse { string value = 1 }
|
||||
let proto = encode_proto_string(1, oauth_token.as_bytes());
|
||||
eprintln!("[stub-ext] → serving token ({} bytes) for key={key:?}", oauth_token.len());
|
||||
|
||||
// Data envelope: flag=0x00, length, data
|
||||
envelope.push(0x00u8);
|
||||
envelope.extend_from_slice(&(proto.len() as u32).to_be_bytes());
|
||||
envelope.extend_from_slice(&proto);
|
||||
} else {
|
||||
eprintln!("[stub-ext] ⚠ no OAuth token available for key={key:?}");
|
||||
}
|
||||
} else if path.contains("StoreSecretValue") {
|
||||
// Parse and log what the LS is storing (for debugging)
|
||||
let key = extract_proto_string(&body, 1).unwrap_or_default();
|
||||
let value = extract_proto_string(&body, 2).unwrap_or_default();
|
||||
let val_preview = if value.len() > 32 {
|
||||
format!("{}...", &value[..32])
|
||||
} else {
|
||||
value
|
||||
};
|
||||
eprintln!("[stub-ext] ← StoreSecretValue key={key:?} value={val_preview:?}");
|
||||
}
|
||||
|
||||
if path.contains("PushUnifiedStateSyncUpdate") {
|
||||
// Unary proto — respond with empty PushUnifiedStateSyncUpdateResponse (0 bytes body)
|
||||
let header = "HTTP/1.1 200 OK\r\n\
|
||||
Content-Type: application/proto\r\n\
|
||||
Content-Length: 0\r\n\
|
||||
Connection: close\r\n\
|
||||
\r\n";
|
||||
let _ = writer.write_all(header.as_bytes());
|
||||
let _ = writer.flush();
|
||||
return;
|
||||
}
|
||||
|
||||
// End-of-stream envelope: flag=0x02, length=2, data="{}"
|
||||
envelope.push(0x02u8);
|
||||
envelope.extend_from_slice(&2u32.to_be_bytes());
|
||||
envelope.extend_from_slice(b"{}");
|
||||
|
||||
// Respond with 200 OK + Connection: close (one request per connection)
|
||||
let header = format!(
|
||||
"HTTP/1.1 200 OK\r\n\
|
||||
Content-Type: application/connect+proto\r\n\
|
||||
Content-Length: {}\r\n\
|
||||
Connection: close\r\n\
|
||||
\r\n",
|
||||
envelope.len()
|
||||
);
|
||||
let _ = writer.write_all(header.as_bytes());
|
||||
let _ = writer.write_all(&envelope);
|
||||
let _ = writer.flush();
|
||||
}
|
||||
|
||||
/// Extract a string field from a protobuf message by field number.
|
||||
/// Only handles simple string (wire type 2) fields at the top level.
|
||||
fn extract_proto_string(buf: &[u8], target_field: u32) -> Option<String> {
|
||||
let mut i = 0;
|
||||
while i < buf.len() {
|
||||
// Read field tag (varint)
|
||||
let (tag, consumed) = decode_varint(&buf[i..])?;
|
||||
i += consumed;
|
||||
let field_num = (tag >> 3) as u32;
|
||||
let wire_type = (tag & 0x07) as u8;
|
||||
|
||||
match wire_type {
|
||||
0 => {
|
||||
// Varint — skip
|
||||
let (_, c) = decode_varint(&buf[i..])?;
|
||||
i += c;
|
||||
}
|
||||
1 => {
|
||||
// 64-bit — skip 8 bytes
|
||||
i += 8;
|
||||
}
|
||||
2 => {
|
||||
// Length-delimited
|
||||
let (len, c) = decode_varint(&buf[i..])?;
|
||||
i += c;
|
||||
let len = len as usize;
|
||||
if i + len > buf.len() {
|
||||
return None;
|
||||
}
|
||||
if field_num == target_field {
|
||||
return String::from_utf8(buf[i..i + len].to_vec()).ok();
|
||||
}
|
||||
i += len;
|
||||
}
|
||||
5 => {
|
||||
// 32-bit — skip 4 bytes
|
||||
i += 4;
|
||||
}
|
||||
_ => return None, // Unknown wire type
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Decode a protobuf varint, returning (value, bytes_consumed).
|
||||
fn decode_varint(buf: &[u8]) -> Option<(u64, usize)> {
|
||||
let mut result: u64 = 0;
|
||||
let mut shift = 0u32;
|
||||
for (i, &byte) in buf.iter().enumerate() {
|
||||
result |= ((byte & 0x7f) as u64) << shift;
|
||||
if byte & 0x80 == 0 {
|
||||
return Some((result, i + 1));
|
||||
}
|
||||
shift += 7;
|
||||
if shift >= 64 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Encode a string value as a protobuf field (field_num, wire type 2).
|
||||
fn encode_proto_string(field_num: u32, data: &[u8]) -> Vec<u8> {
|
||||
let tag = (field_num << 3) | 2; // wire type 2 = length-delimited
|
||||
let mut buf = Vec::with_capacity(1 + 5 + data.len());
|
||||
encode_varint(&mut buf, tag as u64);
|
||||
encode_varint(&mut buf, data.len() as u64);
|
||||
buf.extend_from_slice(data);
|
||||
buf
|
||||
}
|
||||
|
||||
/// Encode a u64 as a protobuf varint.
|
||||
fn encode_varint(buf: &mut Vec<u8>, mut val: u64) {
|
||||
loop {
|
||||
let byte = (val & 0x7f) as u8;
|
||||
val >>= 7;
|
||||
if val == 0 {
|
||||
buf.push(byte);
|
||||
break;
|
||||
}
|
||||
buf.push(byte | 0x80);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -35,9 +35,17 @@ pub async fn warmup_sequence(backend: &Backend) {
|
||||
];
|
||||
|
||||
for (method, body) in calls {
|
||||
match backend.call_json(method, body).await {
|
||||
Ok((status, _)) => debug!("Warmup {method}: {status}"),
|
||||
Err(e) => warn!("Warmup {method} failed: {e}"),
|
||||
// Timeout per call — in headless mode, the LS can't reach Google's API
|
||||
// so these would hang forever without a timeout. Warmup is best-effort.
|
||||
match tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
backend.call_json(method, body),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok((status, _))) => debug!("Warmup {method}: {status}"),
|
||||
Ok(Err(e)) => warn!("Warmup {method} failed: {e}"),
|
||||
Err(_) => warn!("Warmup {method} timed out"),
|
||||
}
|
||||
// Small delay between calls — real webview doesn't blast them instantly
|
||||
let delay = rand::thread_rng().gen_range(50..200);
|
||||
|
||||
Reference in New Issue
Block a user