feat: MITM interception for standalone LS with UID isolation
- Spawn standalone LS as dedicated 'antigravity-ls' user via sudo - UID-scoped iptables redirect (port 443 → MITM proxy) via mitm-redirect.sh - Combined CA bundle (system CAs + MITM CA) for Go TLS trust - Transparent TLS interception with chunked response detection - Google SSE parser for streamGenerateContent usage extraction - Timeouts on all MITM operations (TLS handshake, upstream, idle) - Forward response data immediately (no buffering) - Per-model token usage capture (input, output, thinking) - Update docs and known issues to reflect resolved TLS blocker
This commit is contained in:
119
KNOWN_ISSUES.md
119
KNOWN_ISSUES.md
@@ -1,92 +1,62 @@
|
|||||||
# Known Issues & Future Work
|
# Known Issues & Future Work
|
||||||
|
|
||||||
All fixable issues from the original report have been resolved. The remaining
|
All critical blockers have been resolved. MITM interception is fully working
|
||||||
items require either architectural changes, new features, or deep investigation
|
in standalone mode with UID-scoped iptables redirection.
|
||||||
of the Go language server binary.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔴 Blockers (Require Deep Investigation)
|
## ✅ Resolved
|
||||||
|
|
||||||
### 1. LS Go LLM Client Ignores System TLS Trust Store
|
### ~~LS Go LLM Client Ignores System TLS Trust Store~~
|
||||||
|
|
||||||
**File:** `docs/mitm-interception-status.md`
|
**Status: SOLVED (2026-02-14)**
|
||||||
|
|
||||||
The LS binary's Go HTTP client for LLM API calls uses a custom `tls.Config` that
|
Previously the #1 blocker. The standalone LS (`--standalone` flag) now routes
|
||||||
does **not** trust system CAs or honor `SSL_CERT_FILE`. Our MITM proxy can route
|
all LLM API traffic through the MITM proxy with full decryption.
|
||||||
traffic but not decrypt it.
|
|
||||||
|
|
||||||
**Investigation status:** All practical approaches have been tried and failed:
|
**Solution:**
|
||||||
|
|
||||||
- iptables REDIRECT → redirect loop + broke all HTTPS traffic
|
1. **UID-scoped iptables** — `scripts/mitm-redirect.sh` creates an `antigravity-ls`
|
||||||
- DNS redirect → same TLS trust failure
|
system user. iptables redirects only that UID's port-443 traffic → MITM port.
|
||||||
- LD_PRELOAD → Go doesn't use libc for syscalls
|
2. **Combined CA bundle** — The Go client honors `SSL_CERT_FILE` when set on
|
||||||
- SSLKEYLOGFILE → Go doesn't support it
|
the standalone process. A combined bundle (system CAs + MITM CA) is written
|
||||||
|
to `/tmp/antigravity-mitm-combined-ca.pem`.
|
||||||
|
3. **`sudo -u` spawning** — The proxy spawns the LS as the `antigravity-ls` user,
|
||||||
|
so only the standalone LS traffic is intercepted. No impact on other software.
|
||||||
|
4. **Google SSE parsing** — MITM parses `streamGenerateContent?alt=sse` responses
|
||||||
|
and extracts `promptTokenCount`, `candidatesTokenCount`, `thoughtsTokenCount`.
|
||||||
|
|
||||||
**Remaining options (untried):**
|
**Verified:** `/v1/usage` returns per-model token usage from intercepted traffic.
|
||||||
|
|
||||||
- Binary patching Go TLS verification (fragile, breaks on updates)
|
|
||||||
- Full standalone LS control (see issue #2)
|
|
||||||
- eBPF/ptrace syscall interception (complex)
|
|
||||||
- Network namespace isolation (complex setup)
|
|
||||||
|
|
||||||
**Confidence: <30%** — all easy paths exhausted. Requires reverse engineering the Go binary's TLS setup.
|
|
||||||
|
|
||||||
**See:** `docs/mitm-interception-status.md` for full analysis
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2. Standalone LS Cascades Silently Fail
|
## 🟡 Medium (Architecture / Future Work)
|
||||||
|
|
||||||
**File:** `docs/standalone-ls-todo.md`
|
### 1. Cascade Correlation Is Heuristic
|
||||||
|
|
||||||
Standalone LS (outside Antigravity) accepts `StartCascade` RPCs without error
|
|
||||||
but cascade never progresses. No output.
|
|
||||||
|
|
||||||
**Suspected blockers:**
|
|
||||||
|
|
||||||
- Missing auth context (OAuth token propagation)
|
|
||||||
- Different Unleash feature flags between main and standalone instances
|
|
||||||
- Missing initialization steps (`LoadCodeAssist`, `OnboardUser`)
|
|
||||||
- Missing extension server callbacks (`WriteCascadeEdit`, `ExecuteCommand`)
|
|
||||||
|
|
||||||
**Confidence: <30%** — too many unknowns. Needs systematic debugging with the standalone LS.
|
|
||||||
|
|
||||||
**See:** `docs/standalone-ls-todo.md` for investigation plan
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Medium (Architecture / Future Work)
|
|
||||||
|
|
||||||
### 3. Cascade Correlation Is Heuristic
|
|
||||||
|
|
||||||
**File:** `src/mitm/intercept.rs` — `extract_cascade_hint()`
|
**File:** `src/mitm/intercept.rs` — `extract_cascade_hint()`
|
||||||
|
|
||||||
The MITM proxy matches intercepted API traffic to cascade IDs heuristically:
|
The MITM proxy matches intercepted API traffic to cascade IDs heuristically.
|
||||||
|
Currently all intercepted usage is stored under `_latest` because the Google
|
||||||
|
SSE request body is empty (`content_length=0` — the LS sends the request body
|
||||||
|
via chunked encoding that isn't captured in the hint extractor).
|
||||||
|
|
||||||
- HTTP/1.1 path: scans JSON body for `metadata.user_id` or `workspace_id`
|
**Impact:** Usage shows up in `/v1/usage` aggregate stats but isn't correlated
|
||||||
- gRPC/H2 path: recursively searches proto fields for UUID strings
|
to specific cascades. Not blocking — aggregate usage is the primary use case.
|
||||||
|
|
||||||
If neither method finds a match, usage is stored under `_latest` but never
|
|
||||||
consumed (since `take_usage()` requires exact cascade ID match).
|
|
||||||
|
|
||||||
**Confidence: <50%** — can't test without working MITM interception (blocked by issue #1). The heuristic is reasonable but unverified against real traffic.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 4. Request Modification Not Implemented
|
### 2. Request Modification Not Implemented
|
||||||
|
|
||||||
**File:** `src/mitm/proxy.rs` — `modify_requests: bool`
|
**File:** `src/mitm/proxy.rs` — `modify_requests: bool`
|
||||||
|
|
||||||
The `MitmConfig.modify_requests` flag is plumbed through the entire call chain
|
The `MitmConfig.modify_requests` flag is plumbed through but hardcoded to `false`.
|
||||||
but hardcoded to `false`. No modification logic exists. This is intentional
|
Reserved for future request mutation features (e.g., injecting custom system
|
||||||
scaffolding for future use.
|
prompts, modifying model selection).
|
||||||
|
|
||||||
**Status:** Not a bug — reserved for potential request mutation features.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 5. Polling-Based Cascade Updates vs Streaming RPC
|
### 3. Polling-Based Cascade Updates vs Streaming RPC
|
||||||
|
|
||||||
**File:** `src/api/polling.rs`
|
**File:** `src/api/polling.rs`
|
||||||
|
|
||||||
@@ -94,23 +64,26 @@ We poll `GetCascadeTrajectorySteps` on a timer. The LS has a
|
|||||||
`StreamCascadeReactiveUpdates` streaming gRPC method that pushes updates
|
`StreamCascadeReactiveUpdates` streaming gRPC method that pushes updates
|
||||||
in real-time. Polling works but adds latency.
|
in real-time. Polling works but adds latency.
|
||||||
|
|
||||||
**Status:** Functional but suboptimal. Switching to streaming requires
|
**Status:** Functional but suboptimal.
|
||||||
implementing a gRPC streaming client with reconnection handling. Not blocking.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🟢 Low
|
## 🟢 Low
|
||||||
|
|
||||||
### 6. No Integration Tests for MITM Module
|
### 4. MITM Integration Tests
|
||||||
|
|
||||||
Unit tests cover protobuf decoding and intercept parsing (17 tests pass), but
|
Unit tests cover protobuf decoding and intercept parsing (18 tests pass).
|
||||||
no integration tests for:
|
Integration tests for the full MITM pipeline (TLS interception, response
|
||||||
|
parsing, usage recording) would be valuable now that interception works.
|
||||||
|
|
||||||
- TLS interception end-to-end with the generated CA
|
### 5. MITM for Main Antigravity Session
|
||||||
- Full HTTP/1.1 request/response cycle through the proxy
|
|
||||||
- gRPC (HTTP/2) request/response cycle through `h2_handler`
|
|
||||||
- Store recording and retrieval under concurrency
|
|
||||||
|
|
||||||
**Status:** The MITM can't intercept real traffic anyway (blocked by issue #1),
|
The current MITM only works for the standalone LS (`--standalone` mode).
|
||||||
so integration tests would be somewhat hypothetical. Worth adding when the TLS
|
Intercepting the main Antigravity session's LS is harder because:
|
||||||
blocker is resolved.
|
|
||||||
|
- The main LS is managed by the Antigravity app, not by us
|
||||||
|
- UID-scoped iptables can't target it without affecting all user traffic
|
||||||
|
- The `mitm-wrapper.sh` approach sets env vars but the LLM client ignores
|
||||||
|
`HTTPS_PROXY` unless `detect_and_use_proxy` is ENABLED via init metadata
|
||||||
|
|
||||||
|
**Workaround:** Use `--standalone` mode for all proxy traffic.
|
||||||
|
|||||||
@@ -1,275 +1,144 @@
|
|||||||
# MITM Traffic Interception — Research & Status
|
# MITM Traffic Interception — Status
|
||||||
|
|
||||||
## Goal
|
## Status: ✅ FULLY WORKING (Standalone Mode)
|
||||||
|
|
||||||
Capture the LS's LLM API traffic (requests + responses, including system prompts
|
MITM interception is operational for the standalone LS. The proxy intercepts,
|
||||||
and token usage) by routing it through our MITM proxy.
|
decrypts, and parses all LLM API traffic with per-model token usage capture.
|
||||||
|
|
||||||
## Key Discovery: How the LS Makes LLM API Calls
|
## How It Works
|
||||||
|
|
||||||
The LS does **NOT** use gRPC for LLM API calls. It uses:
|
```
|
||||||
|
Client → Proxy (8741) → Standalone LS (as antigravity-ls user)
|
||||||
- **Protocol**: Standard HTTPS POST with Server-Sent Events (SSE)
|
↓ (port 443 traffic)
|
||||||
- **Endpoint**: `https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse`
|
iptables REDIRECT (UID-scoped)
|
||||||
- **HTTP client**: `ApiServerClientV2` — a Go HTTP client that creates its own `tls.Config`
|
↓
|
||||||
and transport, **ignoring `HTTPS_PROXY` by default**
|
MITM Proxy (8742)
|
||||||
|
↓ (TLS decrypt + parse SSE)
|
||||||
The Go HTTP client for LLM API calls is separate from the one used for Unleash
|
Google API (daily-cloudcode-pa.googleapis.com)
|
||||||
(feature flags) and other auxiliary traffic. The Unleash client respects proxy
|
|
||||||
settings, but the LLM client does not.
|
|
||||||
|
|
||||||
## What We Tried
|
|
||||||
|
|
||||||
### 1. Extension Patch — `detectAndUseProxy` ✅ Partial
|
|
||||||
|
|
||||||
**Status**: Applied and still active. Harmless.
|
|
||||||
|
|
||||||
The extension sends a protobuf field `detect_and_use_proxy` (field 34) to the LS
|
|
||||||
during initialization. By default, it's set to `UNSPECIFIED` (0), meaning the LS
|
|
||||||
ignores proxy env vars.
|
|
||||||
|
|
||||||
**Patch applied:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo sed -i -E 's/detectAndUseProxy=[^,;)]+/detectAndUseProxy=1/g' \
|
|
||||||
/usr/share/antigravity/resources/app/extensions/antigravity/dist/extension.js
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Enum values:**
|
### Components
|
||||||
|
|
||||||
- 0 = `DETECT_AND_USE_PROXY_UNSPECIFIED` (default, ignore proxy)
|
1. **UID-scoped iptables** (`scripts/mitm-redirect.sh`)
|
||||||
- 1 = `DETECT_AND_USE_PROXY_ENABLED`
|
- Creates `antigravity-ls` system user
|
||||||
- 2 = `DETECT_AND_USE_PROXY_DISABLED`
|
- iptables rule: redirect UID's port-443 → MITM port
|
||||||
|
- Only the standalone LS is affected — no side effects on other software
|
||||||
|
|
||||||
**Result:** Unleash/aux traffic now routes through `HTTPS_PROXY`. But the LLM API
|
2. **Combined CA bundle** (`src/standalone.rs`)
|
||||||
client (`ApiServerClientV2`) has its own transport that ignores this flag. LLM
|
- Go's `SSL_CERT_FILE` replaces (not appends) the system trust store
|
||||||
calls still go direct to Google.
|
- Proxy concatenates system CAs + MITM CA → `/tmp/antigravity-mitm-combined-ca.pem`
|
||||||
|
- Set as `SSL_CERT_FILE` on the standalone LS process
|
||||||
|
|
||||||
**Verify:** `grep -o 'detectAndUseProxy=[^;]*' /usr/share/antigravity/resources/app/extensions/antigravity/dist/extension.js`
|
3. **`sudo -u` spawning** (`src/standalone.rs`)
|
||||||
→ should show `detectAndUseProxy=1`
|
- If `antigravity-ls` user exists, LS is spawned via `sudo -n -u antigravity-ls`
|
||||||
|
- Env vars passed via `/usr/bin/env KEY=VALUE` args
|
||||||
|
- Falls back to current user if the dedicated user doesn't exist
|
||||||
|
|
||||||
**Re-apply after updates:** Yes, must re-apply after every Antigravity update.
|
4. **Google SSE parser** (`src/mitm/intercept.rs`)
|
||||||
|
- Parses `data: {"response": {"usageMetadata": {...}}}` events
|
||||||
|
- Extracts `promptTokenCount`, `candidatesTokenCount`, `thoughtsTokenCount`
|
||||||
|
- Handles both Google and Anthropic SSE formats
|
||||||
|
|
||||||
### 2. MITM Wrapper (`mitm-wrapper.sh`) ✅ Works for Env Vars
|
5. **Transparent proxy** (`src/mitm/proxy.rs`)
|
||||||
|
- Detects iptables-redirected connections via TLS ClientHello SNI
|
||||||
|
- Terminates TLS with dynamically generated certs
|
||||||
|
- Forwards HTTP/1.1 requests upstream with real DNS resolution (`dig @8.8.8.8`)
|
||||||
|
- Chunked response detection for fast completion
|
||||||
|
|
||||||
Sets `HTTPS_PROXY` and `SSL_CERT_FILE` on the LS process by wrapping the binary.
|
## What We Tried (Historical)
|
||||||
|
|
||||||
**How it works:**
|
### 1. Extension Patch — `detectAndUseProxy` ✅ Still Active
|
||||||
|
|
||||||
1. Renames real binary to `.real`
|
Patches `detectAndUseProxy=1` in the extension JS. Makes auxiliary traffic
|
||||||
2. Places a shell script wrapper at the original path
|
(Unleash, etc.) honor `HTTPS_PROXY`. Harmless, still applied.
|
||||||
3. Wrapper sets env vars and execs the real binary with all original args
|
|
||||||
|
|
||||||
**Result:** The wrapper correctly sets env vars on the LS process (verified via
|
### 2. MITM Wrapper (`mitm-wrapper.sh`) ⚠️ Superseded
|
||||||
`/proc/<PID>/environ`). Combined with the extension patch, Unleash traffic routes
|
|
||||||
through the proxy. But LLM API calls still bypass — the `ApiServerClientV2` Go
|
|
||||||
HTTP client doesn't honor `HTTPS_PROXY`.
|
|
||||||
|
|
||||||
### 3. iptables REDIRECT — ALL Port 443 ❌ Failed
|
Sets env vars on the main LS process. Works for routing but the main LS's
|
||||||
|
LLM client ignores `HTTPS_PROXY`. Superseded by standalone mode.
|
||||||
|
|
||||||
Redirected all outbound port 443 traffic from the user's UID to the MITM proxy.
|
### 3. iptables REDIRECT (All Traffic) ❌ Abandoned
|
||||||
|
|
||||||
**Problems encountered:**
|
Redirected ALL port-443 traffic. Caused redirect loops, broke other HTTPS
|
||||||
|
traffic. Replaced by UID-scoped redirect.
|
||||||
|
|
||||||
1. **Redirect loop** — proxy's own upstream connections got caught by iptables,
|
### 4. DNS Redirect (`/etc/hosts`) ❌ Abandoned
|
||||||
creating infinite loops → fd exhaustion → crash
|
|
||||||
2. **Fixed loop with GID bypass** — running proxy with `sg mitm-bypass` and
|
|
||||||
excluding GID in iptables. This fixed the loop.
|
|
||||||
3. **Broke Antigravity** — ALL HTTPS traffic (telegram, discord, microsoft
|
|
||||||
telemetry, extension marketplace, etc.) went through the proxy. The TLS
|
|
||||||
passthrough worked technically but was too disruptive.
|
|
||||||
4. **TLS trust failure** — even with the MITM wrapper setting `SSL_CERT_FILE`,
|
|
||||||
the LS's Go LLM client likely uses a custom `tls.Config` with its own root
|
|
||||||
CAs, not the system pool. So it rejected our MITM CA cert.
|
|
||||||
|
|
||||||
**Abandoned.** Too disruptive, and the fundamental TLS trust issue remained.
|
Same TLS trust issue as #3. Unnecessary with UID-scoped iptables.
|
||||||
|
|
||||||
### 4. DNS Redirect (`/etc/hosts`) ❌ Failed
|
### 5. Standalone LS + UID-scoped iptables ✅ WORKING
|
||||||
|
|
||||||
Redirected only `daily-cloudcode-pa.googleapis.com` to 127.0.0.1 via `/etc/hosts`,
|
Current solution. Full MITM interception with zero side effects.
|
||||||
then used a targeted iptables rule for `127.0.0.1:443` only.
|
|
||||||
|
|
||||||
**Problems:**
|
## The Original Blocker (SOLVED)
|
||||||
|
|
||||||
- Same TLS trust issue — the Go LLM client rejected our MITM CA
|
> The LS's Go LLM HTTP client uses a custom `tls.Config` that does NOT read
|
||||||
- Needed `dig @8.8.8.8` bypass for upstream resolution (implemented but untested)
|
> from `SSL_CERT_FILE` or the system CA store.
|
||||||
|
|
||||||
**Abandoned.** TLS trust is the blocker.
|
**This turned out to be wrong.** The Go client DOES honor `SSL_CERT_FILE` when:
|
||||||
|
|
||||||
## The Core Blocker
|
- The env var is set BEFORE the process starts (not injected later)
|
||||||
|
- The value contains a combined bundle (system CAs + custom CA)
|
||||||
|
- `SSL_CERT_DIR` is set to `/dev/null` to force exclusive use of `SSL_CERT_FILE`
|
||||||
|
|
||||||
**The LS's Go LLM HTTP client (`ApiServerClientV2`) uses a custom `tls.Config`
|
The standalone LS gives us full control over the process environment at spawn
|
||||||
that does NOT read from `SSL_CERT_FILE` or the system CA store.** It likely has
|
time, which is why this approach works while the wrapper approach didn't.
|
||||||
its own hardcoded/embedded root CAs.
|
|
||||||
|
|
||||||
This means:
|
|
||||||
|
|
||||||
- Even if we redirect traffic to our MITM proxy ✅
|
|
||||||
- Even if the MITM generates valid certs for the domain ✅
|
|
||||||
- The LS rejects the cert because it doesn't trust our CA ❌
|
|
||||||
|
|
||||||
## Potential Solutions (Untried)
|
|
||||||
|
|
||||||
### A. Binary Patching
|
|
||||||
|
|
||||||
Patch the Go binary to accept our CA or disable cert verification.
|
|
||||||
|
|
||||||
- Find the `tls.Config` setup in the binary
|
|
||||||
- Modify `InsecureSkipVerify` to `true`, or inject our CA cert DER bytes
|
|
||||||
- Very fragile, breaks on updates
|
|
||||||
|
|
||||||
### B. LD_PRELOAD Hook
|
|
||||||
|
|
||||||
Hook `connect()` syscall to redirect traffic.
|
|
||||||
|
|
||||||
- **Won't work** for Go — Go uses raw syscalls, not libc wrappers
|
|
||||||
|
|
||||||
### C. Network Namespace
|
|
||||||
|
|
||||||
Run the LS in an isolated network namespace with custom routing.
|
|
||||||
|
|
||||||
- Complex setup, but clean isolation
|
|
||||||
- The standalone LS work would feed into this
|
|
||||||
|
|
||||||
### D. Standalone LS with Full Control
|
|
||||||
|
|
||||||
Get standalone LS cascades working (see `docs/standalone-ls-todo.md`), then
|
|
||||||
have full control over the process environment, including:
|
|
||||||
|
|
||||||
- Custom CA trust
|
|
||||||
- Custom DNS resolution
|
|
||||||
- Custom proxy settings
|
|
||||||
- Network namespace isolation
|
|
||||||
**This is probably the best long-term approach.**
|
|
||||||
|
|
||||||
### E. Kernel-level TLS Interception (eBPF)
|
|
||||||
|
|
||||||
Use eBPF to intercept TLS records pre-encryption.
|
|
||||||
|
|
||||||
- Very powerful, can read plaintext before encryption
|
|
||||||
- Complex, requires kernel support (>= 4.18)
|
|
||||||
- Tools: `bpftrace`, custom eBPF programs, `ecapture`
|
|
||||||
|
|
||||||
### F. `SSLKEYLOGFILE` + Passive Capture
|
|
||||||
|
|
||||||
- Go doesn't support `SSLKEYLOGFILE` (confirmed by testing)
|
|
||||||
- Could patch the binary to enable it, but same fragility as option A
|
|
||||||
|
|
||||||
### G. ptrace-based Interception
|
|
||||||
|
|
||||||
Use `ptrace` to intercept `write()`/`sendmsg()` syscalls on TLS sockets.
|
|
||||||
|
|
||||||
- Can read plaintext data being written to TLS connections
|
|
||||||
- Tools: `strace -e trace=write -p <PID>` (but output is messy)
|
|
||||||
- Better: custom ptrace tool that filters for TLS socket FDs
|
|
||||||
|
|
||||||
## Technical Details
|
## Technical Details
|
||||||
|
|
||||||
|
### API Endpoint
|
||||||
|
|
||||||
|
`POST https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse`
|
||||||
|
|
||||||
|
### SSE Response Format
|
||||||
|
|
||||||
|
```
|
||||||
|
data: {"response": {"candidates": [{"content": {"role": "model", "parts": [{"text": "..."}]}}],
|
||||||
|
"usageMetadata": {"promptTokenCount": 1514, "candidatesTokenCount": 25,
|
||||||
|
"totalTokenCount": 1539, "thoughtsTokenCount": 52},
|
||||||
|
"modelVersion": "gemini-3-flash"}, "traceId": "...", "metadata": {}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Last event includes `"finishReason": "STOP"` in the candidate.
|
||||||
|
|
||||||
|
### Other Intercepted Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Type | Content |
|
||||||
|
| --------------------------- | -------- | ---------------- |
|
||||||
|
| `fetchUserInfo` | Protobuf | User info |
|
||||||
|
| `loadCodeAssist` | Protobuf | Extension config |
|
||||||
|
| `fetchAvailableModels` | Protobuf | Model catalog |
|
||||||
|
| `webDocsOptions` | Protobuf | Docs config |
|
||||||
|
| `streamGenerateContent` | SSE/JSON | LLM responses ✅ |
|
||||||
|
| `recordCodeAssistMetrics` | Protobuf | Telemetry |
|
||||||
|
| `recordTrajectoryAnalytics` | Protobuf | Telemetry |
|
||||||
|
|
||||||
### Model IDs
|
### Model IDs
|
||||||
|
|
||||||
| Placeholder | Model |
|
| Placeholder | Model |
|
||||||
| ------------------------- | ------------------- |
|
| ----------------------- | ------------------- |
|
||||||
| `MODEL_PLACEHOLDER_M18` | Gemini 3 Flash |
|
| `MODEL_PLACEHOLDER_M18` | Gemini 3 Flash |
|
||||||
| `MODEL_PLACEHOLDER_M8` | Gemini 3 Pro (High) |
|
| `MODEL_PLACEHOLDER_M8` | Gemini 3 Pro (High) |
|
||||||
| `MODEL_PLACEHOLDER_M7` | Gemini 3 Pro (Low) |
|
| `MODEL_PLACEHOLDER_M7` | Gemini 3 Pro (Low) |
|
||||||
| `MODEL_PLACEHOLDER_M26` | Claude Opus 4.6 |
|
| `MODEL_PLACEHOLDER_M26` | Claude Opus 4.6 |
|
||||||
| `MODEL_PLACEHOLDER_M12` | Claude Opus 4.5 |
|
| `MODEL_PLACEHOLDER_M12` | Claude Opus 4.5 |
|
||||||
| `MODEL_CLAUDE_4_5_SONNET` | Claude Sonnet 4.5 |
|
|
||||||
|
|
||||||
### LS Binary Location
|
### Setup
|
||||||
|
|
||||||
`/usr/share/antigravity/resources/app/extensions/antigravity/bin/language_server_linux_x64`
|
|
||||||
|
|
||||||
### API Endpoint
|
|
||||||
|
|
||||||
`https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse`
|
|
||||||
|
|
||||||
### Protobuf Field 34 — `detect_and_use_proxy`
|
|
||||||
|
|
||||||
- Part of the init metadata sent from extension to LS via stdin
|
|
||||||
- Enum: `DetectAndUseProxy` (0=UNSPECIFIED, 1=ENABLED, 2=DISABLED)
|
|
||||||
- Controls whether auxiliary HTTP clients honor `HTTPS_PROXY`
|
|
||||||
- Does NOT control the LLM API client
|
|
||||||
|
|
||||||
### Unleash Feature Flags
|
|
||||||
|
|
||||||
- Authorization: `*:production.e44558998bfc35ea9584dc65858e4485fdaa5d7ef46903e0c67712d1`
|
|
||||||
- Endpoint: `antigravity-unleash.goog`
|
|
||||||
- App name: `codeium-language-server`
|
|
||||||
|
|
||||||
### Files Modified (Current State)
|
|
||||||
|
|
||||||
- `extension.js` — `detectAndUseProxy=1` (harmless, keeps working)
|
|
||||||
- Everything else — clean/reverted
|
|
||||||
|
|
||||||
## Code Changes Made (in the proxy)
|
|
||||||
|
|
||||||
1. **Transparent proxy mode** (`src/mitm/proxy.rs`) — supports iptables REDIRECT
|
|
||||||
by detecting raw TLS ClientHello and extracting SNI
|
|
||||||
2. **CryptoProvider init** (`src/main.rs`) — prevents rustls panic under load
|
|
||||||
3. **PID detection fix** (`src/backend.rs`) — prefers `.real` binary PID over
|
|
||||||
wrapper shell script PID
|
|
||||||
4. **SS fallback** (`src/backend.rs`) — discovers LS port via `ss` when log file
|
|
||||||
doesn't have it
|
|
||||||
5. **DNS bypass** (`src/mitm/proxy.rs`) — `connect_upstream` resolves via
|
|
||||||
`dig @8.8.8.8` to bypass `/etc/hosts`
|
|
||||||
6. **Scripts** — `dns-redirect.sh`, `iptables-redirect.sh` (both functional)
|
|
||||||
|
|
||||||
## Cleanup Checklist
|
|
||||||
|
|
||||||
If things are broken, undo in this order:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Remove iptables rules
|
# One-time setup (creates user + iptables rule)
|
||||||
sudo ./scripts/iptables-redirect.sh uninstall
|
sudo ./scripts/mitm-redirect.sh install
|
||||||
sudo ./scripts/dns-redirect.sh uninstall
|
|
||||||
|
|
||||||
# 2. Remove /etc/hosts entries (verify manually)
|
# Run proxy with standalone LS + MITM
|
||||||
sudo grep -v "antigravity-mitm" /etc/hosts | sudo tee /etc/hosts.tmp && sudo mv /etc/hosts.tmp /etc/hosts
|
RUST_LOG=info ./target/release/antigravity-proxy --standalone
|
||||||
|
|
||||||
# 3. Uninstall wrapper
|
# Check usage
|
||||||
sudo ./scripts/mitm-wrapper.sh uninstall
|
curl -s http://localhost:8741/v1/usage | jq .
|
||||||
|
|
||||||
# 4. Remove system CA
|
|
||||||
sudo rm -f /usr/local/share/ca-certificates/antigravity-mitm.crt
|
|
||||||
sudo update-ca-certificates
|
|
||||||
|
|
||||||
# 5. Restart Antigravity
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Next Steps
|
### Cleanup
|
||||||
|
|
||||||
→ See `docs/standalone-ls-todo.md` for standalone LS isolation work
|
```bash
|
||||||
→ See `docs/ls-binary-analysis.md` for comprehensive binary reverse engineering
|
# Remove iptables rule + user
|
||||||
|
sudo ./scripts/mitm-redirect.sh uninstall
|
||||||
## New Findings (from binary analysis)
|
```
|
||||||
|
|
||||||
### Alternative to Polling: `StreamCascadeReactiveUpdates`
|
|
||||||
|
|
||||||
The LS has a streaming gRPC method `StreamCascadeReactiveUpdates` that pushes
|
|
||||||
cascade state changes in real-time via server-sent streaming. The extension uses
|
|
||||||
this instead of polling `GetCascadeTrajectorySteps`.
|
|
||||||
|
|
||||||
**Potential improvement:** If we switch from polling to this streaming RPC, we'd
|
|
||||||
get lower latency and less backend traffic. However, our current polling approach
|
|
||||||
works reliably and doesn't require maintaining a long-lived gRPC stream.
|
|
||||||
|
|
||||||
### Quota Endpoint: `retrieveUserQuota`
|
|
||||||
|
|
||||||
The `PredictionService/RetrieveUserQuota` gRPC method and
|
|
||||||
`v1internal:retrieveUserQuota` REST endpoint provide quota/credit information.
|
|
||||||
This could be used to implement a proper `/v1/quota` endpoint instead of
|
|
||||||
scraping the LS's own quota tracking.
|
|
||||||
|
|
||||||
### `internalAtomicAgenticChat`
|
|
||||||
|
|
||||||
A REST endpoint that appears to handle the entire agentic chat loop atomically
|
|
||||||
(tool calls + responses in one request?). Investigation needed to understand
|
|
||||||
the request/response format.
|
|
||||||
|
|
||||||
### Credits System
|
|
||||||
|
|
||||||
The `google/internal/cloud/code/v1internal/credits` proto package exists with
|
|
||||||
`Credits_CreditType` enum. The `CASCADE_ENFORCE_QUOTA` config key controls
|
|
||||||
whether quotas are enforced. Related methods: `AddExtraFlexCreditsInternal`,
|
|
||||||
`GetTeamCreditEntries`, `GetPlanStatus`.
|
|
||||||
|
|||||||
@@ -1,87 +1,78 @@
|
|||||||
# Standalone LS for Proxy Isolation
|
# Standalone LS for Proxy Isolation
|
||||||
|
|
||||||
## Goal
|
## Status: ✅ FULLY IMPLEMENTED (incl. MITM interception)
|
||||||
|
|
||||||
Route ALL proxy traffic through a standalone LS instance instead of the real one,
|
The standalone LS is fully working via `--standalone` flag on the proxy.
|
||||||
so development/testing/proxying never interferes with active coding sessions.
|
All cascade types (sync, streaming, multi-turn) and all endpoints work.
|
||||||
|
MITM interception captures real token usage from Google's API.
|
||||||
|
|
||||||
## Current State
|
## Implementation
|
||||||
|
|
||||||
The proxy currently talks to the **real** LS spawned by Antigravity.
|
**Module:** `src/standalone.rs`
|
||||||
This is risky — a bad cascade or proxy bug can disrupt the coding conversation.
|
|
||||||
|
|
||||||
## What Works
|
The proxy spawns a standalone LS as a child process:
|
||||||
|
|
||||||
- Standalone LS starts fine with custom init metadata via stdin protobuf
|
1. Discovers `extension_server_port` and `csrf_token` from the real LS (via `/proc/PID/cmdline`)
|
||||||
- Connects to the main extension server (`-extension_server_port`)
|
2. Picks a random free port
|
||||||
- Accepts cascade requests (returns cascadeId)
|
3. Builds init metadata protobuf (via `proto::build_init_metadata()`)
|
||||||
- With `detect_and_use_proxy = ENABLED` (field 34 = 2), honors `HTTPS_PROXY`
|
4. Spawns the LS binary with correct args and env vars
|
||||||
|
5. Feeds init metadata via stdin, then closes it
|
||||||
|
6. Waits for TCP readiness (retry loop)
|
||||||
|
7. Kills the child on proxy shutdown (via `Drop`)
|
||||||
|
|
||||||
## What Doesn't Work
|
### UID Isolation (MITM mode)
|
||||||
|
|
||||||
- **Cascades silently fail** — the LS accepts the request but never processes it
|
When `scripts/mitm-redirect.sh install` has been run:
|
||||||
- No planner invocation, no upstream API call, no logs beyond startup
|
|
||||||
- 9 lines of log after 40s wait
|
|
||||||
- Main LS logs show zero trace of the standalone's cascade
|
|
||||||
|
|
||||||
## Suspected Blockers (investigate in order)
|
1. The `antigravity-ls` system user exists
|
||||||
|
2. iptables redirects that UID's port-443 traffic → MITM proxy port
|
||||||
|
3. The proxy spawns the LS via `sudo -n -u antigravity-ls`
|
||||||
|
4. Environment variables (`SSL_CERT_FILE`, etc.) are passed via `/usr/bin/env`
|
||||||
|
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
|
||||||
|
|
||||||
1. **Auth context** — standalone may not receive OAuth token from extension server
|
## Usage
|
||||||
- Check: does the standalone's `GetUserStatus` return valid auth?
|
|
||||||
- The extension server might only share tokens with the "primary" LS
|
|
||||||
|
|
||||||
2. **Unleash feature flags** — cascade processing gated by flags the standalone doesn't fetch
|
|
||||||
- The standalone connects to Unleash via the proxy, but might not get the right flags
|
|
||||||
- Check: compare Unleash responses between main and standalone
|
|
||||||
|
|
||||||
3. **Workspace indexing** — planner might require indexed workspace state
|
|
||||||
- The standalone's workspace (`/tmp/antigravity-standalone`) is empty
|
|
||||||
- Try: point it at a real workspace with actual files
|
|
||||||
|
|
||||||
4. **Extension server coupling** — cascade might need the extension to "drive" it
|
|
||||||
- The chat panel in the extension might send additional RPCs to progress the cascade
|
|
||||||
- Check: trace what RPCs the extension sends after StartCascade
|
|
||||||
|
|
||||||
## Investigation Plan
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Launch with max verbosity
|
# Setup (one-time, requires sudo)
|
||||||
echo "$METADATA" | base64 -d | \
|
sudo ./scripts/mitm-redirect.sh install
|
||||||
timeout 90 "$LS_BIN" \
|
|
||||||
-v 5 \
|
|
||||||
-server_port 42200 \
|
|
||||||
... > /tmp/standalone-verbose.log 2>&1 &
|
|
||||||
|
|
||||||
# 2. Check auth status
|
# Run
|
||||||
curl -sk "https://127.0.0.1:42200/exa.language_server_pb.LanguageServerService/GetUserStatus" \
|
RUST_LOG=info ./target/release/antigravity-proxy --standalone
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "x-codeium-csrf-token: $CSRF" \
|
|
||||||
-d '{}'
|
|
||||||
|
|
||||||
# 3. Send cascade and watch logs in real-time
|
# Check intercepted usage
|
||||||
tail -f /tmp/standalone-verbose.log &
|
curl -s http://localhost:8741/v1/usage | jq .
|
||||||
curl -sk "https://127.0.0.1:42200/.../StartCascade" ...
|
|
||||||
|
|
||||||
# 4. Compare Unleash flags
|
|
||||||
# Main LS unleash vs standalone unleash
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
## Key Technical Details
|
## Key Technical Details
|
||||||
|
|
||||||
- Init metadata protobuf field 34 = `detect_and_use_proxy` (enum: 0=UNSPECIFIED, 1=ENABLED, 2=DISABLED)
|
- Init metadata protobuf field 34 = `detect_and_use_proxy` (1=ENABLED)
|
||||||
- Model IDs: M18=Flash, M8=Pro-High, M7=Pro-Low, M26=Opus4.6, M12=Opus4.5
|
- 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`
|
- 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`
|
- API endpoint: `daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse`
|
||||||
|
- SSE response format: `{"response": {"usageMetadata": {"promptTokenCount", "candidatesTokenCount", "thoughtsTokenCount"}, "modelVersion": "..."}}`
|
||||||
|
|
||||||
## New Leads (from binary analysis)
|
## Test Results (2026-02-14)
|
||||||
|
|
||||||
- **`GetUnleashData`** — LS method to fetch Unleash flags directly. Could compare
|
| Endpoint | Result |
|
||||||
main vs standalone to check if flags differ.
|
| --------------------------------- | ------------------------- |
|
||||||
- **`GetStaticExperimentStatus`** / `SetBaseExperiments` / `UpdateDevExperiments` —
|
| `GET /health` | ✅ |
|
||||||
experiment management. Standalone might be missing experiment overrides.
|
| `GET /v1/models` | ✅ 5 models |
|
||||||
- **`FetchAdminControls`** — admin-level controls that might gate cascade execution.
|
| `GET /v1/sessions` | ✅ |
|
||||||
- **`LoadCodeAssist`** — initialization step that might be required before cascades work.
|
| `GET /v1/quota` | ✅ real plan/credits |
|
||||||
- **`GetUserStatus` vs `GetUserMemories`** — check if standalone has auth context
|
| `GET /v1/usage` | ✅ real MITM tokens |
|
||||||
by calling both.
|
| `POST /v1/responses` (sync) | ✅ |
|
||||||
|
| `POST /v1/responses` (stream) | ✅ SSE events |
|
||||||
→ See `docs/ls-binary-analysis.md` for full RPC method catalog.
|
| `POST /v1/responses` (multi-turn) | ✅ context preserved |
|
||||||
|
| `POST /v1/chat/completions` | ✅ |
|
||||||
|
| MITM interception | ✅ TLS decrypt + parse |
|
||||||
|
| MITM usage capture | ✅ per-model token counts |
|
||||||
|
| UID isolation | ✅ no side effects |
|
||||||
|
|||||||
181
scripts/mitm-redirect.sh
Executable file
181
scripts/mitm-redirect.sh
Executable file
@@ -0,0 +1,181 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# mitm-redirect.sh — UID-scoped iptables redirect for MITM interception
|
||||||
|
#
|
||||||
|
# Creates a dedicated system user for the standalone LS and adds an iptables
|
||||||
|
# rule that ONLY redirects traffic from that user's UID. No /etc/hosts
|
||||||
|
# modification, no system-wide changes.
|
||||||
|
#
|
||||||
|
# Flow:
|
||||||
|
# 1. Standalone LS runs as 'antigravity-ls' user (via sudo -u)
|
||||||
|
# 2. iptables catches :443 traffic from that UID only → REDIRECT to MITM port
|
||||||
|
# 3. MITM terminates TLS (Go client trusts our CA via SSL_CERT_FILE)
|
||||||
|
# 4. MITM forwards upstream, captures usage
|
||||||
|
#
|
||||||
|
# What this does NOT affect:
|
||||||
|
# - Your real Antigravity session (different UID)
|
||||||
|
# - Any other software on your PC (different UID)
|
||||||
|
# - DNS resolution (no /etc/hosts changes)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sudo ./scripts/mitm-redirect.sh install [mitm_port]
|
||||||
|
# sudo ./scripts/mitm-redirect.sh uninstall [mitm_port]
|
||||||
|
# sudo ./scripts/mitm-redirect.sh status
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MITM_PORT="${2:-8742}"
|
||||||
|
LS_USER="antigravity-ls"
|
||||||
|
DATA_DIR="/tmp/antigravity-standalone"
|
||||||
|
LS_BINARY="/usr/share/antigravity/resources/app/extensions/antigravity/bin/language_server_linux_x64"
|
||||||
|
SUDOERS_FILE="/etc/sudoers.d/antigravity-ls"
|
||||||
|
|
||||||
|
install() {
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
echo "Error: must run as root (sudo)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[mitm-redirect] Installing UID-scoped iptables redirect → :$MITM_PORT"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# ── 1. Create system user ───────────────────────────────────────────
|
||||||
|
if id "$LS_USER" &>/dev/null; then
|
||||||
|
echo " ✓ user '$LS_USER' already exists (uid=$(id -u "$LS_USER"))"
|
||||||
|
else
|
||||||
|
useradd -r -s /usr/sbin/nologin -d "$DATA_DIR" "$LS_USER"
|
||||||
|
echo " + created user '$LS_USER' (uid=$(id -u "$LS_USER"))"
|
||||||
|
fi
|
||||||
|
local LS_UID
|
||||||
|
LS_UID=$(id -u "$LS_USER")
|
||||||
|
|
||||||
|
# ── 2. Create data directory (writable by both users) ────────────────
|
||||||
|
mkdir -p "$DATA_DIR/.gemini"
|
||||||
|
chmod 1777 "$DATA_DIR" "$DATA_DIR/.gemini"
|
||||||
|
echo " + data dir: $DATA_DIR (mode 1777, writable by all)"
|
||||||
|
|
||||||
|
# ── 3. Sudoers entry ────────────────────────────────────────────────
|
||||||
|
# Allow the invoking user (SUDO_USER) to run ANY command as antigravity-ls.
|
||||||
|
# This is needed for the proxy to spawn the LS binary.
|
||||||
|
local REAL_USER="${SUDO_USER:-$(logname 2>/dev/null || whoami)}"
|
||||||
|
cat > "$SUDOERS_FILE" <<EOF
|
||||||
|
# Allow $REAL_USER to run commands as $LS_USER (for antigravity proxy)
|
||||||
|
$REAL_USER ALL=($LS_USER) NOPASSWD: ALL
|
||||||
|
EOF
|
||||||
|
chmod 440 "$SUDOERS_FILE"
|
||||||
|
echo " + sudoers: $REAL_USER can run as $LS_USER"
|
||||||
|
|
||||||
|
# ── 4. iptables REDIRECT (scoped to UID) ────────────────────────────
|
||||||
|
# Remove existing rule first (idempotent)
|
||||||
|
iptables -t nat -D OUTPUT -m owner --uid-owner "$LS_UID" \
|
||||||
|
-p tcp --dport 443 -j REDIRECT --to-port "$MITM_PORT" 2>/dev/null || true
|
||||||
|
|
||||||
|
iptables -t nat -A OUTPUT -m owner --uid-owner "$LS_UID" \
|
||||||
|
-p tcp --dport 443 -j REDIRECT --to-port "$MITM_PORT"
|
||||||
|
echo " + iptables: uid=$LS_UID :443 → :$MITM_PORT"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "[mitm-redirect] ✓ Installed (only affects uid=$LS_UID)"
|
||||||
|
echo " Restart the proxy to take effect:"
|
||||||
|
echo " RUST_LOG=info ./target/release/antigravity-proxy --standalone"
|
||||||
|
}
|
||||||
|
|
||||||
|
uninstall() {
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
echo "Error: must run as root (sudo)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[mitm-redirect] Removing UID-scoped iptables redirect"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Remove iptables rule
|
||||||
|
if id "$LS_USER" &>/dev/null; then
|
||||||
|
local LS_UID
|
||||||
|
LS_UID=$(id -u "$LS_USER")
|
||||||
|
iptables -t nat -D OUTPUT -m owner --uid-owner "$LS_UID" \
|
||||||
|
-p tcp --dport 443 -j REDIRECT --to-port "$MITM_PORT" 2>/dev/null || true
|
||||||
|
echo " - iptables: removed REDIRECT rule for uid=$LS_UID"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove sudoers entry
|
||||||
|
rm -f "$SUDOERS_FILE"
|
||||||
|
echo " - sudoers: removed $SUDOERS_FILE"
|
||||||
|
|
||||||
|
# Clean data dir
|
||||||
|
rm -rf "$DATA_DIR"
|
||||||
|
echo " - data dir: removed $DATA_DIR"
|
||||||
|
|
||||||
|
# Optionally remove user (commented out — user might want to keep it)
|
||||||
|
# userdel "$LS_USER" 2>/dev/null || true
|
||||||
|
echo " ℹ user '$LS_USER' kept (run 'sudo userdel $LS_USER' to remove)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "[mitm-redirect] ✓ Uninstalled."
|
||||||
|
}
|
||||||
|
|
||||||
|
status() {
|
||||||
|
echo "[mitm-redirect] Status"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Check user
|
||||||
|
if id "$LS_USER" &>/dev/null; then
|
||||||
|
local LS_UID
|
||||||
|
LS_UID=$(id -u "$LS_USER")
|
||||||
|
echo " user: $LS_USER (uid=$LS_UID) ✓"
|
||||||
|
else
|
||||||
|
echo " user: $LS_USER (not found) ✗"
|
||||||
|
echo
|
||||||
|
echo " Run: sudo $0 install"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check sudoers
|
||||||
|
if [[ -f "$SUDOERS_FILE" ]]; then
|
||||||
|
echo " sudoers: $SUDOERS_FILE ✓"
|
||||||
|
else
|
||||||
|
echo " sudoers: $SUDOERS_FILE (not found) ✗"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check iptables
|
||||||
|
echo " iptables:"
|
||||||
|
if iptables -t nat -L OUTPUT -n 2>/dev/null | grep -q "owner UID match.*$LS_UID"; then
|
||||||
|
iptables -t nat -L OUTPUT -n -v 2>/dev/null | grep "owner UID" | sed 's/^/ /'
|
||||||
|
else
|
||||||
|
echo " (no rules for uid=$LS_UID)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check data dir
|
||||||
|
echo " data dir: $(ls -ld "$DATA_DIR" 2>/dev/null || echo '(not found)')"
|
||||||
|
|
||||||
|
# Test sudo
|
||||||
|
echo
|
||||||
|
echo " sudo test:"
|
||||||
|
if sudo -n -u "$LS_USER" true 2>/dev/null; then
|
||||||
|
echo " ✓ can run as $LS_USER without password"
|
||||||
|
else
|
||||||
|
echo " ✗ cannot run as $LS_USER (check sudoers)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-help}" in
|
||||||
|
install) install ;;
|
||||||
|
uninstall) uninstall ;;
|
||||||
|
status) status ;;
|
||||||
|
*)
|
||||||
|
echo "Usage: sudo $0 {install|uninstall|status} [mitm_port]"
|
||||||
|
echo
|
||||||
|
echo "Redirects ONLY the standalone LS's outgoing :443 traffic through"
|
||||||
|
echo "the MITM proxy using UID-scoped iptables rules."
|
||||||
|
echo
|
||||||
|
echo "This does NOT affect:"
|
||||||
|
echo " - Your real Antigravity coding session"
|
||||||
|
echo " - Any other software on your PC"
|
||||||
|
echo " - DNS resolution (/etc/hosts is untouched)"
|
||||||
|
echo
|
||||||
|
echo " install [port] Create user + iptables REDIRECT for that UID"
|
||||||
|
echo " uninstall [port] Remove iptables rule + sudoers"
|
||||||
|
echo " status Show current state"
|
||||||
|
echo
|
||||||
|
echo "Default MITM port: 8742"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -83,6 +83,34 @@ impl Backend {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a Backend with known connection details (for standalone LS).
|
||||||
|
///
|
||||||
|
/// Skips auto-discovery — the caller provides the port, CSRF, and OAuth token.
|
||||||
|
pub fn new_with_config(
|
||||||
|
port: u16,
|
||||||
|
csrf: String,
|
||||||
|
oauth_token: String,
|
||||||
|
) -> Result<Self, String> {
|
||||||
|
let inner = BackendInner {
|
||||||
|
pid: "standalone".to_string(),
|
||||||
|
csrf,
|
||||||
|
https_port: port.to_string(),
|
||||||
|
oauth_token,
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = wreq::Client::builder()
|
||||||
|
.emulation(wreq_util::Emulation::Chrome142)
|
||||||
|
.cert_verification(false)
|
||||||
|
.verify_hostname(false)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("wreq client build failed: {e}"))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
inner: RwLock::new(inner),
|
||||||
|
client,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Re-discover language server connection details.
|
/// Re-discover language server connection details.
|
||||||
/// Runs blocking I/O on a spawn_blocking thread to avoid starving tokio.
|
/// Runs blocking I/O on a spawn_blocking thread to avoid starving tokio.
|
||||||
pub async fn refresh(&self) -> Result<(), String> {
|
pub async fn refresh(&self) -> Result<(), String> {
|
||||||
|
|||||||
91
src/main.rs
91
src/main.rs
@@ -11,6 +11,7 @@ mod mitm;
|
|||||||
mod proto;
|
mod proto;
|
||||||
mod quota;
|
mod quota;
|
||||||
mod session;
|
mod session;
|
||||||
|
mod standalone;
|
||||||
mod warmup;
|
mod warmup;
|
||||||
|
|
||||||
use api::AppState;
|
use api::AppState;
|
||||||
@@ -44,6 +45,10 @@ struct Cli {
|
|||||||
/// MITM proxy port (default: 8742, matches wrapper script)
|
/// MITM proxy port (default: 8742, matches wrapper script)
|
||||||
#[arg(long, default_value_t = 8742)]
|
#[arg(long, default_value_t = 8742)]
|
||||||
mitm_port: u16,
|
mitm_port: u16,
|
||||||
|
|
||||||
|
/// Use a standalone LS (does not touch the real LS)
|
||||||
|
#[arg(long)]
|
||||||
|
standalone: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -85,13 +90,84 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Step 2: Backend discovery ─────────────────────────────────────────────
|
// ── Step 2: Backend discovery (or standalone LS spawn) ─────────────────────
|
||||||
let backend = Arc::new(match Backend::new() {
|
let standalone_ls = if cli.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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Build MITM config if MITM is enabled
|
||||||
|
let mitm_cfg = if !cli.no_mitm {
|
||||||
|
let ca_path = dirs_data_dir()
|
||||||
|
.join("mitm-ca.pem")
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
Some(standalone::StandaloneMitmConfig {
|
||||||
|
proxy_addr: format!("http://127.0.0.1:{}", cli.mitm_port),
|
||||||
|
ca_cert_path: ca_path,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let ls = match standalone::StandaloneLS::spawn(&main_config, mitm_cfg.as_ref()) {
|
||||||
|
Ok(ls) => ls,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Fatal: failed to spawn standalone LS: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Wait for it to be ready
|
||||||
|
let rt_ls_port = ls.port;
|
||||||
|
let rt_ls_csrf = ls.csrf.clone();
|
||||||
|
tokio::task::block_in_place(|| {
|
||||||
|
tokio::runtime::Handle::current().block_on(async {
|
||||||
|
if let Err(e) = ls.wait_ready(10).await {
|
||||||
|
eprintln!("Fatal: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
info!(port = rt_ls_port, "Standalone LS ready");
|
||||||
|
Some((ls, rt_ls_port, rt_ls_csrf))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let backend = Arc::new(if let Some((_, port, ref csrf)) = standalone_ls {
|
||||||
|
// Build backend pointing at standalone LS
|
||||||
|
let oauth = 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();
|
||||||
|
match Backend::new_with_config(port, csrf.clone(), oauth) {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("Fatal: {e}");
|
eprintln!("Fatal: {e}");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal mode: discover existing LS
|
||||||
|
match Backend::new() {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Fatal: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let (pid, https_port, csrf, token) = backend.info().await;
|
let (pid, https_port, csrf, token) = backend.info().await;
|
||||||
@@ -151,8 +227,15 @@ async fn main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Periodic backend refresh — keeps LS connection details fresh
|
// Periodic backend refresh — keeps LS connection details fresh
|
||||||
|
// (skip in standalone mode — the port is fixed and discover() would overwrite it)
|
||||||
|
let is_standalone = cli.standalone;
|
||||||
let refresh_backend = Arc::clone(&state.backend);
|
let refresh_backend = Arc::clone(&state.backend);
|
||||||
let refresh_handle = tokio::spawn(async move {
|
let refresh_handle = tokio::spawn(async move {
|
||||||
|
if is_standalone {
|
||||||
|
// In standalone mode, the backend config is fixed — no refresh needed
|
||||||
|
std::future::pending::<()>().await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
|
tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
|
||||||
if let Err(e) = refresh_backend.refresh().await {
|
if let Err(e) = refresh_backend.refresh().await {
|
||||||
@@ -178,6 +261,10 @@ async fn main() {
|
|||||||
if let Some(h) = mitm_handle {
|
if let Some(h) = mitm_handle {
|
||||||
h.abort();
|
h.abort();
|
||||||
}
|
}
|
||||||
|
// Kill standalone LS if we spawned one
|
||||||
|
if let Some((mut ls, _, _)) = standalone_ls {
|
||||||
|
ls.kill();
|
||||||
|
}
|
||||||
// Remove stale MITM port file
|
// Remove stale MITM port file
|
||||||
let _ = std::fs::remove_file(dirs_data_dir().join("mitm-port"));
|
let _ = std::fs::remove_file(dirs_data_dir().join("mitm-port"));
|
||||||
info!("Server shutdown complete");
|
info!("Server shutdown complete");
|
||||||
|
|||||||
@@ -56,9 +56,11 @@ pub struct StreamingAccumulator {
|
|||||||
pub output_tokens: u64,
|
pub output_tokens: u64,
|
||||||
pub cache_creation_input_tokens: u64,
|
pub cache_creation_input_tokens: u64,
|
||||||
pub cache_read_input_tokens: u64,
|
pub cache_read_input_tokens: u64,
|
||||||
|
pub thinking_tokens: u64,
|
||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
pub stop_reason: Option<String>,
|
pub stop_reason: Option<String>,
|
||||||
pub is_complete: bool,
|
pub is_complete: bool,
|
||||||
|
pub api_provider: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StreamingAccumulator {
|
impl StreamingAccumulator {
|
||||||
@@ -66,13 +68,46 @@ impl StreamingAccumulator {
|
|||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Process a single SSE event.
|
/// Process a single SSE event.
|
||||||
pub fn process_event(&mut self, event: &Value) {
|
pub fn process_event(&mut self, event: &Value) {
|
||||||
|
// ── Google format: {"response": {"usageMetadata": {...}, "modelVersion": "..."}} ──
|
||||||
|
if let Some(response) = event.get("response") {
|
||||||
|
// Extract usage metadata (each event has cumulative counts)
|
||||||
|
if let Some(usage) = response.get("usageMetadata") {
|
||||||
|
self.input_tokens = usage["promptTokenCount"].as_u64().unwrap_or(self.input_tokens);
|
||||||
|
self.output_tokens = usage["candidatesTokenCount"].as_u64().unwrap_or(self.output_tokens);
|
||||||
|
self.thinking_tokens = usage["thoughtsTokenCount"].as_u64().unwrap_or(self.thinking_tokens);
|
||||||
|
}
|
||||||
|
if let Some(model) = response["modelVersion"].as_str() {
|
||||||
|
self.model = Some(model.to_string());
|
||||||
|
}
|
||||||
|
// Check for completion in candidates
|
||||||
|
if let Some(candidates) = response.get("candidates").and_then(|c| c.as_array()) {
|
||||||
|
for candidate in candidates {
|
||||||
|
if let Some(reason) = candidate["finishReason"].as_str() {
|
||||||
|
self.stop_reason = Some(reason.to_string());
|
||||||
|
if reason == "STOP" {
|
||||||
|
self.is_complete = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.api_provider = Some("google".to_string());
|
||||||
|
trace!(
|
||||||
|
input = self.input_tokens,
|
||||||
|
output = self.output_tokens,
|
||||||
|
thinking = self.thinking_tokens,
|
||||||
|
complete = self.is_complete,
|
||||||
|
"SSE Google: usage update"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Anthropic format: {"type": "message_start"|"message_delta"|"message_stop"} ──
|
||||||
let event_type = event["type"].as_str().unwrap_or("");
|
let event_type = event["type"].as_str().unwrap_or("");
|
||||||
|
|
||||||
match event_type {
|
match event_type {
|
||||||
"message_start" => {
|
"message_start" => {
|
||||||
// message_start contains the initial usage (input tokens + cache)
|
|
||||||
if let Some(usage) = event.get("message").and_then(|m| m.get("usage")) {
|
if let Some(usage) = event.get("message").and_then(|m| m.get("usage")) {
|
||||||
self.input_tokens = usage["input_tokens"].as_u64().unwrap_or(0);
|
self.input_tokens = usage["input_tokens"].as_u64().unwrap_or(0);
|
||||||
self.cache_creation_input_tokens = usage["cache_creation_input_tokens"].as_u64().unwrap_or(0);
|
self.cache_creation_input_tokens = usage["cache_creation_input_tokens"].as_u64().unwrap_or(0);
|
||||||
@@ -81,36 +116,27 @@ impl StreamingAccumulator {
|
|||||||
if let Some(model) = event.get("message").and_then(|m| m["model"].as_str()) {
|
if let Some(model) = event.get("message").and_then(|m| m["model"].as_str()) {
|
||||||
self.model = Some(model.to_string());
|
self.model = Some(model.to_string());
|
||||||
}
|
}
|
||||||
trace!(
|
self.api_provider = Some("anthropic".to_string());
|
||||||
input = self.input_tokens,
|
trace!(input = self.input_tokens, "SSE Anthropic: message_start");
|
||||||
cache_read = self.cache_read_input_tokens,
|
|
||||||
cache_create = self.cache_creation_input_tokens,
|
|
||||||
"SSE message_start: captured input usage"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
"message_delta" => {
|
"message_delta" => {
|
||||||
// message_delta contains the output usage
|
|
||||||
if let Some(usage) = event.get("usage") {
|
if let Some(usage) = event.get("usage") {
|
||||||
self.output_tokens = usage["output_tokens"].as_u64().unwrap_or(self.output_tokens);
|
self.output_tokens = usage["output_tokens"].as_u64().unwrap_or(self.output_tokens);
|
||||||
}
|
}
|
||||||
if let Some(reason) = event["delta"]["stop_reason"].as_str() {
|
if let Some(reason) = event["delta"]["stop_reason"].as_str() {
|
||||||
self.stop_reason = Some(reason.to_string());
|
self.stop_reason = Some(reason.to_string());
|
||||||
}
|
}
|
||||||
trace!(output = self.output_tokens, "SSE message_delta: updated output tokens");
|
|
||||||
}
|
}
|
||||||
"message_stop" => {
|
"message_stop" => {
|
||||||
self.is_complete = true;
|
self.is_complete = true;
|
||||||
debug!(
|
debug!(
|
||||||
input = self.input_tokens,
|
input = self.input_tokens,
|
||||||
output = self.output_tokens,
|
output = self.output_tokens,
|
||||||
cache_read = self.cache_read_input_tokens,
|
|
||||||
model = ?self.model,
|
model = ?self.model,
|
||||||
"SSE message_stop: stream complete"
|
"SSE Anthropic: stream complete"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
"content_block_start" | "content_block_delta" | "content_block_stop" | "ping" => {
|
"content_block_start" | "content_block_delta" | "content_block_stop" | "ping" => {}
|
||||||
// Content events — no usage data, just pass through
|
|
||||||
}
|
|
||||||
_ => {
|
_ => {
|
||||||
trace!(event_type, "SSE: unknown event type");
|
trace!(event_type, "SSE: unknown event type");
|
||||||
}
|
}
|
||||||
@@ -124,11 +150,11 @@ impl StreamingAccumulator {
|
|||||||
output_tokens: self.output_tokens,
|
output_tokens: self.output_tokens,
|
||||||
cache_creation_input_tokens: self.cache_creation_input_tokens,
|
cache_creation_input_tokens: self.cache_creation_input_tokens,
|
||||||
cache_read_input_tokens: self.cache_read_input_tokens,
|
cache_read_input_tokens: self.cache_read_input_tokens,
|
||||||
thinking_output_tokens: 0,
|
thinking_output_tokens: self.thinking_tokens,
|
||||||
response_output_tokens: 0,
|
response_output_tokens: 0,
|
||||||
model: self.model,
|
model: self.model,
|
||||||
stop_reason: self.stop_reason,
|
stop_reason: self.stop_reason,
|
||||||
api_provider: Some("anthropic".to_string()),
|
api_provider: self.api_provider.unwrap_or_else(|| "unknown".to_string()).into(),
|
||||||
grpc_method: None,
|
grpc_method: None,
|
||||||
captured_at: std::time::SystemTime::now()
|
captured_at: std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ pub async fn run(
|
|||||||
let store = store.clone();
|
let store = store.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = handle_connection(stream, ca, store, modify_requests).await {
|
if let Err(e) = handle_connection(stream, ca, store, modify_requests).await {
|
||||||
debug!(error = %e, "MITM connection error");
|
warn!(error = %e, "MITM connection error");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -310,18 +310,30 @@ async fn handle_intercepted(
|
|||||||
|
|
||||||
let acceptor = TlsAcceptor::from(server_config);
|
let acceptor = TlsAcceptor::from(server_config);
|
||||||
|
|
||||||
// Perform TLS handshake with the client (LS)
|
// Perform TLS handshake with the client (LS) — 10s timeout
|
||||||
let tls_stream = acceptor
|
let tls_stream = match tokio::time::timeout(
|
||||||
.accept(stream)
|
std::time::Duration::from_secs(10),
|
||||||
|
acceptor.accept(stream),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("TLS handshake with client failed for {domain}: {e}"))?;
|
{
|
||||||
|
Ok(Ok(s)) => s,
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
warn!(domain, error = %e, "MITM: TLS handshake FAILED (client rejected cert?)");
|
||||||
|
return Err(format!("TLS handshake with client failed for {domain}: {e}"));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
warn!(domain, "MITM: TLS handshake TIMED OUT after 10s");
|
||||||
|
return Err(format!("TLS handshake timed out for {domain}"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Check negotiated ALPN protocol
|
// Check negotiated ALPN protocol
|
||||||
let alpn = tls_stream.get_ref().1
|
let alpn = tls_stream.get_ref().1
|
||||||
.alpn_protocol()
|
.alpn_protocol()
|
||||||
.map(|p| String::from_utf8_lossy(p).to_string());
|
.map(|p| String::from_utf8_lossy(p).to_string());
|
||||||
|
|
||||||
debug!(domain, alpn = ?alpn, "MITM: TLS handshake successful");
|
info!(domain, alpn = ?alpn, "MITM: TLS handshake successful ✓");
|
||||||
|
|
||||||
match alpn.as_deref() {
|
match alpn.as_deref() {
|
||||||
Some("h2") => {
|
Some("h2") => {
|
||||||
@@ -336,7 +348,7 @@ async fn handle_intercepted(
|
|||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// HTTP/1.1 or no ALPN — use the existing handler
|
// HTTP/1.1 or no ALPN — use the existing handler
|
||||||
debug!(domain, "MITM: routing to HTTP/1.1 handler");
|
info!(domain, "MITM: routing to HTTP/1.1 handler");
|
||||||
handle_http_over_tls(tls_stream, domain, store, modify_requests).await
|
handle_http_over_tls(tls_stream, domain, store, modify_requests).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -382,16 +394,35 @@ async fn handle_http_over_tls(
|
|||||||
|
|
||||||
// Try to resolve the real IP, bypassing /etc/hosts
|
// Try to resolve the real IP, bypassing /etc/hosts
|
||||||
let addr = resolve_upstream(domain).await;
|
let addr = resolve_upstream(domain).await;
|
||||||
|
info!(domain, addr = %addr, "MITM: connecting upstream");
|
||||||
|
|
||||||
let tcp = TcpStream::connect(addr)
|
let tcp = match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(15),
|
||||||
|
TcpStream::connect(&addr),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Connect to upstream {domain}: {e}"))?;
|
{
|
||||||
|
Ok(Ok(s)) => s,
|
||||||
|
Ok(Err(e)) => return Err(format!("Connect to upstream {domain} ({addr}): {e}")),
|
||||||
|
Err(_) => return Err(format!("Connect to upstream {domain} ({addr}): timed out")),
|
||||||
|
};
|
||||||
|
|
||||||
let server_name = rustls::pki_types::ServerName::try_from(domain.to_string())
|
let server_name = rustls::pki_types::ServerName::try_from(domain.to_string())
|
||||||
.map_err(|e| format!("Invalid server name: {e}"))?;
|
.map_err(|e| format!("Invalid server name: {e}"))?;
|
||||||
connector
|
|
||||||
.connect(server_name, tcp)
|
match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(15),
|
||||||
|
connector.connect(server_name, tcp),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("TLS connect to upstream {domain}: {e}"))
|
{
|
||||||
|
Ok(Ok(s)) => {
|
||||||
|
info!(domain, "MITM: upstream TLS connected ✓");
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => Err(format!("TLS connect to upstream {domain}: {e}")),
|
||||||
|
Err(_) => Err(format!("TLS connect to upstream {domain}: timed out")),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve upstream IP bypassing /etc/hosts.
|
/// Resolve upstream IP bypassing /etc/hosts.
|
||||||
@@ -428,8 +459,37 @@ async fn handle_http_over_tls(
|
|||||||
// ── Read the HTTP request from the client ─────────────────────────
|
// ── Read the HTTP request from the client ─────────────────────────
|
||||||
let mut request_buf = Vec::with_capacity(1024 * 64);
|
let mut request_buf = Vec::with_capacity(1024 * 64);
|
||||||
|
|
||||||
|
// 60s timeout on initial read (LS may open connection without sending immediately)
|
||||||
|
const IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let n = match client.read(&mut tmp).await {
|
let read_result = if request_buf.is_empty() {
|
||||||
|
// First read — apply idle timeout
|
||||||
|
match tokio::time::timeout(IDLE_TIMEOUT, client.read(&mut tmp)).await {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => {
|
||||||
|
// Idle timeout — connection pool warmup, no data sent
|
||||||
|
debug!(domain, "MITM: client idle timeout (60s), closing");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Subsequent reads — wait up to 30s for rest of request
|
||||||
|
match tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(30),
|
||||||
|
client.read(&mut tmp),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => {
|
||||||
|
warn!(domain, "MITM: partial request read timed out");
|
||||||
|
return Err("Partial request read timed out".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let n = match read_result {
|
||||||
Ok(0) => return Ok(()), // Client closed connection cleanly
|
Ok(0) => return Ok(()), // Client closed connection cleanly
|
||||||
Ok(n) => n,
|
Ok(n) => n,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -461,12 +521,25 @@ async fn handle_http_over_tls(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
debug!(
|
// Extract request method and path for logging
|
||||||
|
let req_path = {
|
||||||
|
let mut headers = [httparse::EMPTY_HEADER; 64];
|
||||||
|
let mut req = httparse::Request::new(&mut headers);
|
||||||
|
match req.parse(&request_buf) {
|
||||||
|
Ok(httparse::Status::Complete(_)) => {
|
||||||
|
format!("{} {}", req.method.unwrap_or("?"), req.path.unwrap_or("?"))
|
||||||
|
}
|
||||||
|
_ => "?".to_string(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
info!(
|
||||||
domain,
|
domain,
|
||||||
|
req_path = %req_path,
|
||||||
content_length,
|
content_length,
|
||||||
streaming = is_streaming_request,
|
streaming = is_streaming_request,
|
||||||
cascade = ?cascade_hint,
|
cascade = ?cascade_hint,
|
||||||
"MITM: forwarding request to upstream"
|
"MITM: forwarding request"
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Ensure upstream connection is alive ──────────────────────────────
|
// ── Ensure upstream connection is alive ──────────────────────────────
|
||||||
@@ -492,118 +565,139 @@ async fn handle_http_over_tls(
|
|||||||
let conn = upstream.as_mut().unwrap();
|
let conn = upstream.as_mut().unwrap();
|
||||||
|
|
||||||
// ── Stream response back to client ──────────────────────────────────
|
// ── Stream response back to client ──────────────────────────────────
|
||||||
|
// ALWAYS forward data to client immediately (no buffering).
|
||||||
|
// Buffer body on the side for usage parsing.
|
||||||
let mut streaming_acc = StreamingAccumulator::new();
|
let mut streaming_acc = StreamingAccumulator::new();
|
||||||
let mut is_streaming_response = false;
|
let mut is_streaming_response = false;
|
||||||
let mut headers_parsed = false;
|
let mut headers_parsed = false;
|
||||||
// Only buffer response body for non-streaming (for usage parsing)
|
|
||||||
let mut non_streaming_buf: Option<Vec<u8>> = None;
|
|
||||||
// Track if upstream connection is still usable after this response
|
|
||||||
let mut upstream_ok = true;
|
let mut upstream_ok = true;
|
||||||
|
let mut response_body_buf = Vec::new();
|
||||||
// Per-request timeout: 5 minutes (covers large context API calls)
|
let mut response_content_length: Option<usize> = None;
|
||||||
const READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
|
let mut is_chunked = false;
|
||||||
|
let mut got_first_byte = false;
|
||||||
|
let mut header_buf = Vec::with_capacity(8192);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let n = match tokio::time::timeout(READ_TIMEOUT, conn.read(&mut tmp)).await {
|
// 15s idle timeout after first byte, 60s for initial response
|
||||||
Ok(Ok(0)) => {
|
let timeout = if got_first_byte {
|
||||||
// Upstream closed — connection is no longer reusable
|
std::time::Duration::from_secs(15)
|
||||||
upstream_ok = false;
|
} else {
|
||||||
break;
|
std::time::Duration::from_secs(60)
|
||||||
}
|
};
|
||||||
|
|
||||||
|
let n = match tokio::time::timeout(timeout, conn.read(&mut tmp)).await {
|
||||||
|
Ok(Ok(0)) => { upstream_ok = false; break; }
|
||||||
Ok(Ok(n)) => n,
|
Ok(Ok(n)) => n,
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
debug!(domain, error = %e, "MITM: upstream read finished");
|
debug!(domain, error = %e, "MITM: upstream read ended");
|
||||||
upstream_ok = false;
|
upstream_ok = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
warn!(domain, "MITM: upstream read timed out after 5 minutes");
|
if got_first_byte {
|
||||||
|
debug!(domain, "MITM: response idle timeout (complete)");
|
||||||
|
} else {
|
||||||
|
warn!(domain, "MITM: no upstream response in 60s");
|
||||||
|
}
|
||||||
upstream_ok = false;
|
upstream_ok = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
got_first_byte = true;
|
||||||
let chunk = &tmp[..n];
|
let chunk = &tmp[..n];
|
||||||
|
|
||||||
// Check response headers for content-type
|
|
||||||
if !headers_parsed {
|
if !headers_parsed {
|
||||||
// We need to buffer until we see the end of headers
|
header_buf.extend_from_slice(chunk);
|
||||||
let buf = non_streaming_buf.get_or_insert_with(|| Vec::with_capacity(1024 * 64));
|
if let Some(_hdr_end) = find_headers_end(&header_buf) {
|
||||||
buf.extend_from_slice(chunk);
|
|
||||||
if let Some(_hdr_end) = find_headers_end(buf) {
|
|
||||||
// Use httparse for response header parsing
|
|
||||||
let mut resp_headers = [httparse::EMPTY_HEADER; 64];
|
let mut resp_headers = [httparse::EMPTY_HEADER; 64];
|
||||||
let mut resp = httparse::Response::new(&mut resp_headers);
|
let mut resp = httparse::Response::new(&mut resp_headers);
|
||||||
let hdr_end = match resp.parse(buf) {
|
let hdr_end = match resp.parse(&header_buf) {
|
||||||
Ok(httparse::Status::Complete(n)) => n,
|
Ok(httparse::Status::Complete(n)) => n,
|
||||||
_ => _hdr_end, // Fallback to manual detection
|
_ => _hdr_end,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Detect content type and connection handling from parsed headers
|
let mut content_type = String::new();
|
||||||
|
|
||||||
for header in resp.headers.iter() {
|
for header in resp.headers.iter() {
|
||||||
if header.name.eq_ignore_ascii_case("content-type") {
|
if header.name.eq_ignore_ascii_case("content-type") {
|
||||||
if let Ok(val) = std::str::from_utf8(header.value) {
|
if let Ok(v) = std::str::from_utf8(header.value) {
|
||||||
if val.contains("text/event-stream") {
|
content_type = v.to_string();
|
||||||
is_streaming_response = true;
|
if v.contains("text/event-stream") { is_streaming_response = true; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if header.name.eq_ignore_ascii_case("content-length") {
|
||||||
|
if let Ok(v) = std::str::from_utf8(header.value) {
|
||||||
|
response_content_length = v.trim().parse().ok();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if header.name.eq_ignore_ascii_case("connection") {
|
if header.name.eq_ignore_ascii_case("connection") {
|
||||||
if let Ok(val) = std::str::from_utf8(header.value) {
|
if let Ok(v) = std::str::from_utf8(header.value) {
|
||||||
if val.trim().eq_ignore_ascii_case("close") {
|
if v.trim().eq_ignore_ascii_case("close") { upstream_ok = false; }
|
||||||
upstream_ok = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if header.name.eq_ignore_ascii_case("transfer-encoding") {
|
||||||
|
if let Ok(v) = std::str::from_utf8(header.value) {
|
||||||
|
if v.trim().eq_ignore_ascii_case("chunked") { is_chunked = true; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
info!(domain, streaming = is_streaming_response,
|
||||||
|
content_length = ?response_content_length,
|
||||||
|
content_type = %content_type,
|
||||||
|
status = resp.code, "MITM: got response headers");
|
||||||
headers_parsed = true;
|
headers_parsed = true;
|
||||||
|
|
||||||
if is_streaming_response {
|
// Save body for usage parsing
|
||||||
// For streaming, parse any SSE data already in the buffer
|
response_body_buf.extend_from_slice(&header_buf[hdr_end..]);
|
||||||
let body_so_far = String::from_utf8_lossy(&buf[hdr_end..]);
|
|
||||||
if !body_so_far.is_empty() {
|
// Forward to client immediately
|
||||||
parse_streaming_chunk(&body_so_far, &mut streaming_acc);
|
if let Err(e) = client.write_all(&header_buf).await {
|
||||||
}
|
|
||||||
// Forward the accumulated buffer to client
|
|
||||||
if let Err(e) = client.write_all(buf).await {
|
|
||||||
warn!(error = %e, "MITM: write to client failed");
|
warn!(error = %e, "MITM: write to client failed");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
non_streaming_buf = None;
|
|
||||||
continue;
|
if is_streaming_response && hdr_end < header_buf.len() {
|
||||||
|
let body = String::from_utf8_lossy(&header_buf[hdr_end..]);
|
||||||
|
parse_streaming_chunk(&body, &mut streaming_acc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(cl) = response_content_length {
|
||||||
|
if response_body_buf.len() >= cl { break; }
|
||||||
|
}
|
||||||
|
// Check chunked terminator in initial body
|
||||||
|
if is_chunked && has_chunked_terminator(&response_body_buf) {
|
||||||
|
debug!(domain, "MITM: chunked response complete (initial)");
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
// Non-streaming: keep buffering the response body for parsing
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If streaming, parse SSE events and forward immediately
|
// Forward to client immediately
|
||||||
if is_streaming_response {
|
|
||||||
let chunk_str = String::from_utf8_lossy(chunk);
|
|
||||||
parse_streaming_chunk(&chunk_str, &mut streaming_acc);
|
|
||||||
|
|
||||||
if let Err(e) = client.write_all(chunk).await {
|
if let Err(e) = client.write_all(chunk).await {
|
||||||
warn!(error = %e, "MITM: write to client failed (client disconnected?)");
|
warn!(error = %e, "MITM: write to client failed");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
response_body_buf.extend_from_slice(chunk);
|
||||||
// Non-streaming: keep accumulating to parse usage at the end
|
|
||||||
if let Some(ref mut buf) = non_streaming_buf {
|
if is_streaming_response {
|
||||||
buf.extend_from_slice(chunk);
|
let s = String::from_utf8_lossy(chunk);
|
||||||
|
parse_streaming_chunk(&s, &mut streaming_acc);
|
||||||
}
|
}
|
||||||
|
if let Some(cl) = response_content_length {
|
||||||
|
if response_body_buf.len() >= cl { break; }
|
||||||
|
}
|
||||||
|
if is_chunked && has_chunked_terminator(&response_body_buf) {
|
||||||
|
debug!(domain, "MITM: chunked response complete");
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward non-streaming response all at once
|
// Flush client
|
||||||
if !is_streaming_response {
|
let _ = client.flush().await;
|
||||||
if let Some(ref buf) = non_streaming_buf {
|
|
||||||
if let Err(e) = client.write_all(buf).await {
|
|
||||||
warn!(error = %e, "MITM: write to client failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture usage data
|
// Capture usage data
|
||||||
if is_streaming_response {
|
if is_streaming_response {
|
||||||
@@ -611,14 +705,11 @@ async fn handle_http_over_tls(
|
|||||||
let usage = streaming_acc.into_usage();
|
let usage = streaming_acc.into_usage();
|
||||||
store.record_usage(cascade_hint.as_deref(), usage).await;
|
store.record_usage(cascade_hint.as_deref(), usage).await;
|
||||||
}
|
}
|
||||||
} else if let Some(ref buf) = non_streaming_buf {
|
} else if !response_body_buf.is_empty() {
|
||||||
if let Some(body_start) = find_headers_end(buf) {
|
if let Some(usage) = parse_non_streaming_response(&response_body_buf) {
|
||||||
let body = &buf[body_start..];
|
|
||||||
if let Some(usage) = parse_non_streaming_response(body) {
|
|
||||||
store.record_usage(cascade_hint.as_deref(), usage).await;
|
store.record_usage(cascade_hint.as_deref(), usage).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// If upstream closed, drop the connection so next iteration reconnects
|
// If upstream closed, drop the connection so next iteration reconnects
|
||||||
if !upstream_ok {
|
if !upstream_ok {
|
||||||
@@ -652,6 +743,20 @@ async fn handle_passthrough(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Detect end of HTTP chunked transfer encoding.
|
||||||
|
/// A chunked response ends with "0\r\n\r\n" (zero-length chunk + empty trailer).
|
||||||
|
/// We check the tail of the buffer for this pattern.
|
||||||
|
fn has_chunked_terminator(body: &[u8]) -> bool {
|
||||||
|
// The minimal terminator is "0\r\n\r\n" (5 bytes)
|
||||||
|
if body.len() < 5 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Check last 7 bytes to account for possible trailing whitespace
|
||||||
|
let tail = if body.len() > 7 { &body[body.len() - 7..] } else { body };
|
||||||
|
// Look for \r\n0\r\n\r\n anywhere in the tail
|
||||||
|
tail.windows(5).any(|w| w == b"0\r\n\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if buffer contains a complete HTTP request (headers + full body).
|
/// Check if buffer contains a complete HTTP request (headers + full body).
|
||||||
/// Uses `httparse` for zero-copy, case-insensitive header parsing.
|
/// Uses `httparse` for zero-copy, case-insensitive header parsing.
|
||||||
fn has_complete_http_request(buf: &[u8]) -> bool {
|
fn has_complete_http_request(buf: &[u8]) -> bool {
|
||||||
|
|||||||
45
src/proto.rs
45
src/proto.rs
@@ -62,6 +62,51 @@ pub fn varint_field(field: u32, val: u64) -> Vec<u8> {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Init metadata builder (for standalone LS stdin) ─────────────────────────
|
||||||
|
|
||||||
|
/// Build the init metadata protobuf that the LS expects on stdin at startup.
|
||||||
|
///
|
||||||
|
/// This replaces the Python snippet in `standalone-ls.sh` with proper Rust encoding.
|
||||||
|
/// Fields match what the real Antigravity extension sends to the LS.
|
||||||
|
///
|
||||||
|
/// Field layout (from binary analysis):
|
||||||
|
/// 1: api_key (string) — unique session key
|
||||||
|
/// 3: ide_name (string) — "antigravity"
|
||||||
|
/// 4: antigravity_version (string) — e.g. "1.107.0"
|
||||||
|
/// 5: ide_version (string) — e.g. "1.16.5"
|
||||||
|
/// 6: locale (string) — "en_US"
|
||||||
|
/// 10: session_id (string) — unique session identifier
|
||||||
|
/// 11: editor_name (string) — "antigravity"
|
||||||
|
/// 34: detect_and_use_proxy (varint enum) — 1 = ENABLED
|
||||||
|
pub fn build_init_metadata(
|
||||||
|
api_key: &str,
|
||||||
|
antigravity_version: &str,
|
||||||
|
ide_version: &str,
|
||||||
|
session_id: &str,
|
||||||
|
detect_and_use_proxy: u64,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let mut buf = Vec::with_capacity(128);
|
||||||
|
|
||||||
|
// Field 1: api_key
|
||||||
|
buf.extend(proto_string(1, api_key.as_bytes()));
|
||||||
|
// Field 3: ide_name
|
||||||
|
buf.extend(proto_string(3, CLIENT_NAME.as_bytes()));
|
||||||
|
// Field 4: antigravity version
|
||||||
|
buf.extend(proto_string(4, antigravity_version.as_bytes()));
|
||||||
|
// Field 5: IDE/client version
|
||||||
|
buf.extend(proto_string(5, ide_version.as_bytes()));
|
||||||
|
// Field 6: locale
|
||||||
|
buf.extend(proto_string(6, b"en_US"));
|
||||||
|
// Field 10: session_id
|
||||||
|
buf.extend(proto_string(10, session_id.as_bytes()));
|
||||||
|
// Field 11: editor_name
|
||||||
|
buf.extend(proto_string(11, CLIENT_NAME.as_bytes()));
|
||||||
|
// Field 34: detect_and_use_proxy enum (1 = ENABLED)
|
||||||
|
buf.extend(varint_field(34, detect_and_use_proxy));
|
||||||
|
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
// ─── SendUserCascadeMessageRequest builder ───────────────────────────────────
|
// ─── SendUserCascadeMessageRequest builder ───────────────────────────────────
|
||||||
|
|
||||||
/// Build the `SendUserCascadeMessageRequest` protobuf binary.
|
/// Build the `SendUserCascadeMessageRequest` protobuf binary.
|
||||||
|
|||||||
373
src/standalone.rs
Normal file
373
src/standalone.rs
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
//! 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.
|
||||||
|
|
||||||
|
use crate::constants;
|
||||||
|
use crate::proto;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
use tokio::time::{sleep, Duration};
|
||||||
|
use tracing::{debug, info};
|
||||||
|
|
||||||
|
/// Default path to the LS binary.
|
||||||
|
const LS_BINARY_PATH: &str =
|
||||||
|
"/usr/share/antigravity/resources/app/extensions/antigravity/bin/language_server_linux_x64";
|
||||||
|
|
||||||
|
/// App root for ANTIGRAVITY_EDITOR_APP_ROOT env var.
|
||||||
|
const APP_ROOT: &str = "/usr/share/antigravity/resources/app";
|
||||||
|
|
||||||
|
/// Data directory for the standalone LS.
|
||||||
|
const DATA_DIR: &str = "/tmp/antigravity-standalone";
|
||||||
|
|
||||||
|
/// System user for UID-scoped iptables isolation.
|
||||||
|
const LS_USER: &str = "antigravity-ls";
|
||||||
|
|
||||||
|
/// A running standalone LS process.
|
||||||
|
pub struct StandaloneLS {
|
||||||
|
child: Child,
|
||||||
|
pub port: u16,
|
||||||
|
pub csrf: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Config needed from the real (main) LS to bootstrap the standalone one.
|
||||||
|
pub struct MainLSConfig {
|
||||||
|
pub extension_server_port: String,
|
||||||
|
pub csrf: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optional MITM proxy config for the standalone LS.
|
||||||
|
pub struct StandaloneMitmConfig {
|
||||||
|
pub proxy_addr: String, // e.g. "http://127.0.0.1:8742"
|
||||||
|
pub ca_cert_path: String, // path to MITM CA .pem
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StandaloneLS {
|
||||||
|
/// Spawn a standalone LS process.
|
||||||
|
///
|
||||||
|
/// Discovers the main LS's extension server port and CSRF token,
|
||||||
|
/// picks a free port, builds init metadata, and launches the binary.
|
||||||
|
///
|
||||||
|
/// If `mitm_config` is provided, sets HTTPS_PROXY and SSL_CERT_FILE
|
||||||
|
/// so the LS routes LLM API calls through the MITM proxy.
|
||||||
|
pub fn spawn(
|
||||||
|
main_config: &MainLSConfig,
|
||||||
|
mitm_config: Option<&StandaloneMitmConfig>,
|
||||||
|
) -> Result<Self, String> {
|
||||||
|
let port = find_free_port()?;
|
||||||
|
let ts = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
// Build init metadata protobuf
|
||||||
|
let api_key = format!("standalone-api-key-{ts}");
|
||||||
|
let session_id = format!("standalone-session-{ts}");
|
||||||
|
let metadata = proto::build_init_metadata(
|
||||||
|
&api_key,
|
||||||
|
constants::antigravity_version(),
|
||||||
|
constants::client_version(),
|
||||||
|
&session_id,
|
||||||
|
1, // DETECT_AND_USE_PROXY_ENABLED
|
||||||
|
);
|
||||||
|
|
||||||
|
// Setup data dir (mode 1777 so both current user and antigravity-ls can write)
|
||||||
|
let gemini_dir = format!("{DATA_DIR}/.gemini");
|
||||||
|
std::fs::create_dir_all(&gemini_dir)
|
||||||
|
.map_err(|e| format!("Failed to create standalone data dir: {e}"))?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let _ = std::fs::set_permissions(DATA_DIR, std::fs::Permissions::from_mode(0o1777));
|
||||||
|
let _ = std::fs::set_permissions(&gemini_dir, std::fs::Permissions::from_mode(0o1777));
|
||||||
|
}
|
||||||
|
|
||||||
|
// LS args — mirrors standalone-ls.sh but with correct params
|
||||||
|
let args = vec![
|
||||||
|
"-enable_lsp".to_string(),
|
||||||
|
"-extension_server_port".to_string(),
|
||||||
|
main_config.extension_server_port.clone(),
|
||||||
|
"-csrf_token".to_string(),
|
||||||
|
main_config.csrf.clone(),
|
||||||
|
"-server_port".to_string(),
|
||||||
|
port.to_string(),
|
||||||
|
"-workspace_id".to_string(),
|
||||||
|
format!("standalone_{ts}"),
|
||||||
|
"-cloud_code_endpoint".to_string(),
|
||||||
|
"https://daily-cloudcode-pa.googleapis.com".to_string(),
|
||||||
|
"-app_data_dir".to_string(),
|
||||||
|
"antigravity-standalone".to_string(),
|
||||||
|
"-gemini_dir".to_string(),
|
||||||
|
gemini_dir,
|
||||||
|
];
|
||||||
|
|
||||||
|
info!(port, "Spawning standalone LS");
|
||||||
|
debug!(?args, "LS args");
|
||||||
|
|
||||||
|
// Build env vars for the LS process
|
||||||
|
let mut env_vars: Vec<(String, String)> = vec![
|
||||||
|
("ANTIGRAVITY_EDITOR_APP_ROOT".into(), APP_ROOT.into()),
|
||||||
|
];
|
||||||
|
|
||||||
|
// If MITM is enabled, add SSL + proxy env vars
|
||||||
|
if let Some(mitm) = mitm_config {
|
||||||
|
// Go's SSL_CERT_FILE replaces the entire system cert pool, so we
|
||||||
|
// need a combined bundle: system CAs + our MITM CA
|
||||||
|
// Write to /tmp — accessible by antigravity-ls user
|
||||||
|
// (user's ~/.config/ is not traversable by other UIDs)
|
||||||
|
let combined_ca_path = "/tmp/antigravity-mitm-combined-ca.pem".to_string();
|
||||||
|
let system_ca = std::fs::read_to_string("/etc/ssl/certs/ca-certificates.crt")
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mitm_ca = std::fs::read_to_string(&mitm.ca_cert_path)
|
||||||
|
.map_err(|e| format!("Failed to read MITM CA cert: {e}"))?;
|
||||||
|
std::fs::write(&combined_ca_path, format!("{system_ca}\n{mitm_ca}"))
|
||||||
|
.map_err(|e| format!("Failed to write combined CA bundle: {e}"))?;
|
||||||
|
// Make readable by antigravity-ls user
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let _ = std::fs::set_permissions(
|
||||||
|
&combined_ca_path,
|
||||||
|
std::fs::Permissions::from_mode(0o644),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
proxy = %mitm.proxy_addr,
|
||||||
|
ca = %combined_ca_path,
|
||||||
|
"Setting MITM env vars on standalone LS (combined CA bundle)"
|
||||||
|
);
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if 'antigravity-ls' user exists for UID-scoped iptables isolation
|
||||||
|
let use_sudo = 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}"));
|
||||||
|
}
|
||||||
|
c.arg(LS_BINARY_PATH);
|
||||||
|
c.args(&args);
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
debug!("No 'antigravity-ls' user found, spawning LS as current user");
|
||||||
|
let mut c = Command::new(LS_BINARY_PATH);
|
||||||
|
c.args(&args);
|
||||||
|
for (k, v) in &env_vars {
|
||||||
|
c.env(k, v);
|
||||||
|
}
|
||||||
|
c
|
||||||
|
};
|
||||||
|
|
||||||
|
cmd.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null());
|
||||||
|
|
||||||
|
let mut child = cmd
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("Failed to spawn LS binary: {e}"))?;
|
||||||
|
|
||||||
|
// Feed init metadata via stdin, then close it
|
||||||
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
|
stdin
|
||||||
|
.write_all(&metadata)
|
||||||
|
.map_err(|e| format!("Failed to write init metadata to stdin: {e}"))?;
|
||||||
|
// stdin drops here → EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(pid = child.id(), port, "Standalone LS spawned");
|
||||||
|
|
||||||
|
Ok(StandaloneLS {
|
||||||
|
child,
|
||||||
|
port,
|
||||||
|
csrf: main_config.csrf.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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> {
|
||||||
|
info!(port = self.port, "Waiting for standalone LS to be ready...");
|
||||||
|
|
||||||
|
for attempt in 1..=max_attempts {
|
||||||
|
sleep(Duration::from_secs(1)).await;
|
||||||
|
|
||||||
|
// 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(_) => {
|
||||||
|
info!(attempt, "Standalone LS is ready (accepting connections)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
debug!(attempt, error = %e, "LS not ready yet");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!(
|
||||||
|
"Standalone LS failed to become ready after {max_attempts} attempts on port {}",
|
||||||
|
self.port
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the child process is still running.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn is_alive(&mut self) -> bool {
|
||||||
|
matches!(self.child.try_wait(), Ok(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kill the standalone LS process.
|
||||||
|
pub fn kill(&mut self) {
|
||||||
|
info!("Killing standalone LS");
|
||||||
|
let _ = self.child.kill();
|
||||||
|
let _ = self.child.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for StandaloneLS {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discover only the extension_server_port and csrf_token from the running main LS.
|
||||||
|
///
|
||||||
|
/// This does NOT discover the HTTPS port — we don't need to talk to the real LS,
|
||||||
|
/// only steal its extension server connection info.
|
||||||
|
pub fn discover_main_ls_config() -> Result<MainLSConfig, String> {
|
||||||
|
let pid = find_main_ls_pid()?;
|
||||||
|
|
||||||
|
let cmdline = std::fs::read(format!("/proc/{pid}/cmdline"))
|
||||||
|
.map_err(|e| format!("Can't read cmdline for PID {pid}: {e}"))?;
|
||||||
|
let args: Vec<&[u8]> = cmdline.split(|&b| b == 0).collect();
|
||||||
|
|
||||||
|
let mut csrf = String::new();
|
||||||
|
let mut ext_port = String::new();
|
||||||
|
|
||||||
|
for (i, arg) in args.iter().enumerate() {
|
||||||
|
if let Ok(s) = std::str::from_utf8(arg) {
|
||||||
|
match s {
|
||||||
|
"--csrf_token" | "-csrf_token" => {
|
||||||
|
if let Some(next) = args.get(i + 1) {
|
||||||
|
if let Ok(val) = std::str::from_utf8(next) {
|
||||||
|
csrf = val.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--extension_server_port" | "-extension_server_port" => {
|
||||||
|
if let Some(next) = args.get(i + 1) {
|
||||||
|
if let Ok(val) = std::str::from_utf8(next) {
|
||||||
|
ext_port = val.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if csrf.is_empty() {
|
||||||
|
return Err("Could not find CSRF token from main LS".to_string());
|
||||||
|
}
|
||||||
|
if ext_port.is_empty() {
|
||||||
|
return Err("Could not find extension_server_port from main LS".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
pid,
|
||||||
|
ext_port,
|
||||||
|
csrf_len = csrf.len(),
|
||||||
|
"Discovered main LS config"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(MainLSConfig {
|
||||||
|
extension_server_port: ext_port,
|
||||||
|
csrf,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the PID of the main (real) LS process.
|
||||||
|
///
|
||||||
|
/// Checks `/proc/<pid>/exe` to ensure we find the actual LS binary,
|
||||||
|
/// not bash scripts that happen to mention `language_server_linux` in their args.
|
||||||
|
fn find_main_ls_pid() -> Result<String, String> {
|
||||||
|
let proc = std::path::Path::new("/proc");
|
||||||
|
if !proc.exists() {
|
||||||
|
return Err("No /proc filesystem".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = std::fs::read_dir(proc)
|
||||||
|
.map_err(|e| format!("Cannot read /proc: {e}"))?;
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name_str = name.to_string_lossy();
|
||||||
|
// Only numeric dirs (PIDs)
|
||||||
|
if !name_str.chars().all(|c| c.is_ascii_digit()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let exe_link = entry.path().join("exe");
|
||||||
|
if let Ok(target) = std::fs::read_link(&exe_link) {
|
||||||
|
let target_str = target.to_string_lossy().to_string();
|
||||||
|
let target_clean = target_str.trim_end_matches(" (deleted)");
|
||||||
|
// Must be the actual LS binary, not a bash script
|
||||||
|
if target_clean.contains("language_server_linux")
|
||||||
|
|| target_clean.contains("antigravity-language-server")
|
||||||
|
{
|
||||||
|
return Ok(name_str.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err("No main LS process found — Antigravity must be running".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a free TCP port by binding to port 0.
|
||||||
|
fn find_free_port() -> Result<u16, String> {
|
||||||
|
let listener =
|
||||||
|
TcpListener::bind("127.0.0.1:0").map_err(|e| format!("Failed to bind for port: {e}"))?;
|
||||||
|
listener
|
||||||
|
.local_addr()
|
||||||
|
.map(|a| a.port())
|
||||||
|
.map_err(|e| format!("Failed to get port: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the dedicated LS system user exists.
|
||||||
|
///
|
||||||
|
/// When the user exists, the proxy spawns the LS as that UID so iptables
|
||||||
|
/// can scope the :443 redirect to only the standalone LS process.
|
||||||
|
fn has_ls_user() -> bool {
|
||||||
|
Command::new("id")
|
||||||
|
.args(["-u", LS_USER])
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_free_port() {
|
||||||
|
let port = find_free_port().unwrap();
|
||||||
|
assert!(port > 0);
|
||||||
|
// Port should be available — try binding to it
|
||||||
|
let listener = TcpListener::bind(format!("127.0.0.1:{port}"));
|
||||||
|
assert!(listener.is_ok(), "Port {port} should be free");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user