feat: add proxyctl daemon manager, fix standalone LS cleanup

- Add proxyctl CLI script for systemd service management
- Add systemd user service file for background operation
- Fix standalone LS kill: properly track real LS PID via pgrep
  and use sudo kill for cross-user cleanup on shutdown
- Remove deprecated scripts (dns-redirect, iptables-redirect,
  mitm-wrapper, standalone-ls, parse-snapshot)
- Disable tool stripping in MITM for tool call investigation
- Update GEMINI.md with CLI tools documentation
This commit is contained in:
Nikketryhard
2026-02-14 22:14:00 -06:00
parent f64f007421
commit 3e3af85798
9 changed files with 221 additions and 1425 deletions

View File

@@ -5,24 +5,46 @@ OpenAI-compatible proxy that intercepts and relays requests to Google's Antigrav
## Quick Start
```bash
# Build
cargo build --release
# First-time setup (creates user + iptables for MITM)
sudo ./scripts/mitm-redirect.sh install
# Run (spawns standalone LS automatically)
# Start as daemon (builds if needed)
proxyctl start
# Or run directly
RUST_LOG=info ./target/release/antigravity-proxy
# Custom port
RUST_LOG=info ./target/release/antigravity-proxy --port 9000
# Attach to existing LS instead of spawning standalone
RUST_LOG=info ./target/release/antigravity-proxy --no-standalone
```
Default port: **8741**
## CLI Tools
### `proxyctl` — Daemon Manager
Symlinked to `~/.local/bin/proxyctl` for global access. Manages the proxy as a systemd user service.
| Command | Description |
| --------------------- | --------------------------------------- |
| `proxyctl start` | Start the proxy daemon |
| `proxyctl stop` | Stop the proxy daemon |
| `proxyctl restart` | Rebuild + restart |
| `proxyctl rebuild` | Build release binary only |
| `proxyctl status` | Service status + quota + usage |
| `proxyctl logs [N]` | Tail last N lines (default 30) + follow |
| `proxyctl logs-all` | Full log dump (no follow) |
| `proxyctl test [msg]` | Quick test request (gemini-3-flash) |
| `proxyctl health` | Health check |
### `mitm-redirect.sh` — MITM Setup
One-time setup script for UID-scoped iptables traffic redirection.
```bash
sudo ./scripts/mitm-redirect.sh install # create user + iptables rule
sudo ./scripts/mitm-redirect.sh uninstall # remove user + iptables rule
sudo ./scripts/mitm-redirect.sh status # check current state
```
## Endpoints
| Method | Path | Description |

View File

@@ -1,163 +0,0 @@
#!/usr/bin/env bash
# ╔═══════════════════════════════════════════════════════════════════════════╗
# ║ Antigravity MITM — DNS-based redirect for targeted interception ║
# ║ ║
# ║ Instead of redirecting ALL port 443 traffic (which breaks everything), ║
# ║ this uses /etc/hosts to redirect ONLY the LLM API domain to localhost, ║
# ║ then iptables redirects only localhost:443 → MITM port. ║
# ║ ║
# ║ Also adds the MITM CA to the system trust store so Go trusts it. ║
# ╚═══════════════════════════════════════════════════════════════════════════╝
set -euo pipefail
MITM_PORT="${ANTIGRAVITY_MITM_PORT:-8742}"
MITM_CA="${HOME}/.config/antigravity-proxy/mitm-ca.pem"
# If run with sudo, use SUDO_USER's home
if [[ -n "${SUDO_USER:-}" ]]; then
MITM_CA="$(eval echo "~${SUDO_USER}")/.config/antigravity-proxy/mitm-ca.pem"
fi
HOSTS_MARKER="# antigravity-mitm"
API_DOMAINS=(
"daily-cloudcode-pa.googleapis.com"
)
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
cmd_install() {
echo -e "${BOLD}${CYAN}Antigravity MITM DNS Redirect Setup${NC}"
echo -e "────────────────────────────────────"
echo ""
# 1. Add MITM CA to system trust store
if [[ ! -f "$MITM_CA" ]]; then
echo -e " ${RED}${NC} MITM CA not found: ${MITM_CA}"
echo -e " Start the proxy once first to generate it."
exit 1
fi
local sys_cert="/usr/local/share/ca-certificates/antigravity-mitm.crt"
cp "$MITM_CA" "$sys_cert"
update-ca-certificates >/dev/null 2>&1
echo -e " ${GREEN}${NC} MITM CA added to system trust store"
# 2. Add /etc/hosts entries for API domains → 127.0.0.1
# First, cache the real IPs for the MITM to use later
local real_ips_file="/tmp/antigravity-mitm-real-ips"
> "$real_ips_file"
for domain in "${API_DOMAINS[@]}"; do
# Remove old entries
sed -i "/${domain}.*${HOSTS_MARKER}/d" /etc/hosts
# Resolve and cache the real IPs BEFORE redirecting
local real_ip
real_ip=$(dig +short "$domain" 2>/dev/null | grep -E '^[0-9]+\.' | head -1)
if [[ -n "$real_ip" ]]; then
echo "${domain}=${real_ip}" >> "$real_ips_file"
fi
# Add the /etc/hosts redirect
echo "127.0.0.1 ${domain} ${HOSTS_MARKER}" >> /etc/hosts
echo -e " ${GREEN}${NC} /etc/hosts: ${domain} → 127.0.0.1 (real: ${real_ip:-unknown})"
done
# 3. iptables: redirect ONLY 127.0.0.1:443 → MITM port
# This catches only the /etc/hosts redirected domains, nothing else!
iptables -t nat -D OUTPUT -d 127.0.0.1 -p tcp --dport 443 \
-j REDIRECT --to-port "$MITM_PORT" 2>/dev/null || true
iptables -t nat -A OUTPUT -d 127.0.0.1 -p tcp --dport 443 \
-j REDIRECT --to-port "$MITM_PORT"
echo -e " ${GREEN}${NC} iptables: 127.0.0.1:443 → localhost:${MITM_PORT}"
echo ""
echo -e " ${GREEN}Done!${NC}"
echo ""
echo -e " ${BOLD}How it works:${NC}"
echo -e " 1. LS resolves ${API_DOMAINS[0]} → 127.0.0.1 (via /etc/hosts)"
echo -e " 2. LS connects to 127.0.0.1:443"
echo -e " 3. iptables redirects to MITM proxy on :${MITM_PORT}"
echo -e " 4. MITM intercepts, decrypts (CA is trusted), proxies to real Google"
echo ""
echo -e " Real upstream IPs cached in: ${real_ips_file}"
echo -e " Restart Antigravity for changes to take effect."
echo -e " Undo: sudo $0 uninstall"
echo ""
}
cmd_uninstall() {
echo -e "${BOLD}${CYAN}Removing MITM DNS Redirect${NC}"
echo ""
# Remove /etc/hosts entries
sed -i "/${HOSTS_MARKER}/d" /etc/hosts
echo -e " ${GREEN}${NC} Removed /etc/hosts entries"
# Remove iptables rule
iptables -t nat -D OUTPUT -d 127.0.0.1 -p tcp --dport 443 \
-j REDIRECT --to-port "$MITM_PORT" 2>/dev/null || true
echo -e " ${GREEN}${NC} Removed iptables rule"
# Remove system CA (optional)
rm -f /usr/local/share/ca-certificates/antigravity-mitm.crt
update-ca-certificates >/dev/null 2>&1
echo -e " ${GREEN}${NC} Removed MITM CA from system trust store"
echo ""
}
cmd_status() {
echo -e "${BOLD}${CYAN}MITM DNS Redirect Status${NC}"
echo ""
# Check /etc/hosts
local hosts_count
hosts_count=$(grep -c "$HOSTS_MARKER" /etc/hosts 2>/dev/null || echo 0)
if [[ "$hosts_count" -gt 0 ]]; then
echo -e " ${GREEN}${NC} /etc/hosts: ${hosts_count} domain(s) redirected"
grep "$HOSTS_MARKER" /etc/hosts | sed 's/^/ /'
else
echo -e " ${YELLOW}${NC} /etc/hosts: no redirects"
fi
echo ""
# Check iptables
if iptables -t nat -L OUTPUT -n 2>/dev/null | grep -q "127.0.0.1.*REDIRECT.*${MITM_PORT}"; then
echo -e " ${GREEN}${NC} iptables: 127.0.0.1:443 → :${MITM_PORT}"
else
echo -e " ${YELLOW}${NC} iptables: no redirect"
fi
echo ""
# Check system CA
if [[ -f /usr/local/share/ca-certificates/antigravity-mitm.crt ]]; then
echo -e " ${GREEN}${NC} System CA: installed"
else
echo -e " ${YELLOW}${NC} System CA: not installed"
fi
echo ""
}
case "${1:-}" in
install)
cmd_install
;;
uninstall)
cmd_uninstall
;;
status)
cmd_status
;;
*)
echo "Usage: sudo $0 {install|uninstall|status}"
echo ""
echo "Redirects LLM API domain to localhost via /etc/hosts + iptables."
echo "Only intercepts API traffic, everything else is untouched."
exit 1
;;
esac

View File

@@ -1,168 +0,0 @@
#!/usr/bin/env bash
# ╔═══════════════════════════════════════════════════════════════════════════╗
# ║ Antigravity MITM — iptables redirect for transparent interception ║
# ║ ║
# ║ Redirects outbound port 443 traffic to the MITM proxy. ║
# ║ Uses a dedicated GID to exclude the proxy's own upstream traffic, ║
# ║ preventing redirect loops. ║
# ║ ║
# ║ Usage: sudo ./iptables-redirect.sh install ║
# ║ sudo ./iptables-redirect.sh uninstall ║
# ║ sudo ./iptables-redirect.sh status ║
# ╚═══════════════════════════════════════════════════════════════════════════╝
set -euo pipefail
MITM_PORT="${ANTIGRAVITY_MITM_PORT:-8742}"
CHAIN="ANTIGRAVITY_MITM"
BYPASS_GROUP="mitm-bypass"
# Resolve target user (the one whose traffic we redirect)
TARGET_USER="${SUDO_USER:-$(whoami)}"
TARGET_UID=$(id -u "$TARGET_USER" 2>/dev/null || echo "")
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
cmd_install() {
echo -e "${BOLD}${CYAN}Antigravity MITM iptables Setup${NC}"
echo -e "────────────────────────────────"
echo ""
if [[ -z "$TARGET_UID" ]]; then
echo -e " ${RED}${NC} Cannot resolve UID for user '${TARGET_USER}'"
exit 1
fi
# Create bypass group (proxy runs with this GID to avoid redirect loop)
if ! getent group "$BYPASS_GROUP" >/dev/null 2>&1; then
groupadd "$BYPASS_GROUP"
echo -e " ${GREEN}${NC} Created group: ${BYPASS_GROUP}"
else
echo -e " ${GREEN}${NC} Group exists: ${BYPASS_GROUP}"
fi
# Add user to bypass group (so they can use 'sg' to run proxy)
if ! id -nG "$TARGET_USER" 2>/dev/null | grep -qw "$BYPASS_GROUP"; then
usermod -aG "$BYPASS_GROUP" "$TARGET_USER"
echo -e " ${GREEN}${NC} Added ${TARGET_USER} to ${BYPASS_GROUP}"
fi
local bypass_gid
bypass_gid=$(getent group "$BYPASS_GROUP" | cut -d: -f3)
# Check MITM proxy is running
if ! ss -tlnp 2>/dev/null | grep -q ":${MITM_PORT}"; then
echo -e " ${YELLOW}!${NC} MITM proxy not running on :${MITM_PORT} (will work once started)"
else
echo -e " ${GREEN}${NC} MITM proxy listening on :${MITM_PORT}"
fi
# Create our chain
iptables -t nat -N "$CHAIN" 2>/dev/null || true
iptables -t nat -F "$CHAIN"
# THE KEY RULE: redirect port 443 traffic UNLESS it's from the bypass group.
# This prevents redirect loops — the proxy runs with GID=mitm-bypass,
# so its upstream connections to Google are NOT redirected back to itself.
iptables -t nat -A "$CHAIN" \
-m owner ! --gid-owner "$bypass_gid" \
-p tcp --dport 443 \
-j REDIRECT --to-port "$MITM_PORT"
echo -e " ${GREEN}${NC} Redirect rule: tcp/443 → :${MITM_PORT} (skip GID ${bypass_gid})"
# Hook into OUTPUT for target user only
iptables -t nat -D OUTPUT -m owner --uid-owner "$TARGET_UID" \
-p tcp --dport 443 -j "$CHAIN" 2>/dev/null || true
iptables -t nat -A OUTPUT -m owner --uid-owner "$TARGET_UID" \
-p tcp --dport 443 -j "$CHAIN"
echo -e " ${GREEN}${NC} OUTPUT hook: UID ${TARGET_UID} (${TARGET_USER})"
echo ""
echo -e " ${GREEN}Done!${NC}"
echo ""
echo -e " ${BOLD}IMPORTANT:${NC} Run the proxy with the bypass group to avoid loops:"
echo -e " ${CYAN}sg ${BYPASS_GROUP} -c 'RUST_LOG=info ./target/release/antigravity-proxy'${NC}"
echo ""
echo -e " Then restart Antigravity to re-establish connections."
echo -e " Undo: sudo $0 uninstall"
echo ""
}
cmd_uninstall() {
echo -e "${BOLD}${CYAN}Removing iptables MITM redirect${NC}"
echo ""
local target_uid
target_uid=$(id -u "$TARGET_USER" 2>/dev/null || echo "1000")
# Remove jump from OUTPUT
iptables -t nat -D OUTPUT -m owner --uid-owner "$target_uid" \
-p tcp --dport 443 -j "$CHAIN" 2>/dev/null || true
echo -e " ${GREEN}${NC} Removed OUTPUT jump"
# Flush and delete our chain
iptables -t nat -F "$CHAIN" 2>/dev/null || true
iptables -t nat -X "$CHAIN" 2>/dev/null || true
echo -e " ${GREEN}${NC} Removed ${CHAIN} chain"
echo ""
echo -e " ${YELLOW}Note:${NC} Group '${BYPASS_GROUP}' left intact (harmless)."
echo -e " Remove with: sudo groupdel ${BYPASS_GROUP}"
echo ""
}
cmd_status() {
echo -e "${BOLD}${CYAN}iptables MITM Status${NC}"
echo ""
if iptables -t nat -L "$CHAIN" -n 2>/dev/null | grep -q "REDIRECT"; then
echo -e " ${GREEN}${NC} Chain ${CHAIN}: active"
iptables -t nat -L "$CHAIN" -nv --line-numbers 2>/dev/null | \
sed 's/^/ /'
else
echo -e " ${YELLOW}${NC} Chain ${CHAIN}: not installed"
fi
echo ""
if iptables -t nat -L OUTPUT -n 2>/dev/null | grep -q "$CHAIN"; then
echo -e " ${GREEN}${NC} OUTPUT hook: installed"
iptables -t nat -L OUTPUT -n 2>/dev/null | grep "$CHAIN" | sed 's/^/ /'
else
echo -e " ${YELLOW}${NC} OUTPUT hook: not installed"
fi
echo ""
if getent group "$BYPASS_GROUP" >/dev/null 2>&1; then
local gid
gid=$(getent group "$BYPASS_GROUP" | cut -d: -f3)
echo -e " ${GREEN}${NC} Bypass group: ${BYPASS_GROUP} (GID ${gid})"
else
echo -e " ${YELLOW}${NC} Bypass group: not created"
fi
echo ""
}
case "${1:-}" in
install)
cmd_install
;;
uninstall)
cmd_uninstall
;;
status)
cmd_status
;;
*)
echo "Usage: sudo $0 {install|uninstall|status}"
echo ""
echo "Redirects outbound port 443 traffic to the MITM proxy."
echo "The proxy must be run with 'sg mitm-bypass' to avoid redirect loops."
exit 1
;;
esac

View File

@@ -1,329 +0,0 @@
#!/usr/bin/env bash
# ╔═══════════════════════════════════════════════════════════════════════════╗
# ║ Antigravity MITM LS Wrapper ║
# ║ ║
# ║ This script replaces the real Antigravity language server binary. ║
# ║ It injects HTTPS_PROXY and NODE_EXTRA_CA_CERTS environment variables ║
# ║ so the MITM proxy can intercept LS<->API traffic. ║
# ║ ║
# ║ Install: ./mitm-wrapper.sh install ║
# ║ Uninstall: ./mitm-wrapper.sh uninstall ║
# ║ (No args = act as wrapper, exec the real binary with injected env) ║
# ╚═══════════════════════════════════════════════════════════════════════════╝
set -euo pipefail
# ── Config ────────────────────────────────────────────────────────────────────
# Resolve the real user's home (not /root when running under sudo)
if [[ -n "${SUDO_USER:-}" ]]; then
REAL_HOME="$(getent passwd "$SUDO_USER" | cut -d: -f6)"
else
REAL_HOME="$HOME"
fi
MITM_PORT_FILE="${REAL_HOME}/.config/antigravity-proxy/mitm-port"
if [[ -n "${ANTIGRAVITY_MITM_PORT:-}" ]]; then
MITM_PORT="$ANTIGRAVITY_MITM_PORT"
elif [[ -f "$MITM_PORT_FILE" ]]; then
MITM_PORT="$(cat "$MITM_PORT_FILE" 2>/dev/null || echo 8742)"
else
MITM_PORT="8742"
fi
CA_PATH="${REAL_HOME}/.config/antigravity-proxy/mitm-ca.pem"
# Antigravity LS — discovered dynamically from running processes.
# Hardcoded paths are only used as a fallback if no LS process is running.
LS_FALLBACK_DIRS=(
"/usr/share/antigravity/resources/app/extensions/antigravity/bin"
"/opt/Antigravity/resources/app/extensions/antigravity/bin"
"${REAL_HOME}/.local/share/antigravity/resources/app/extensions/antigravity/bin"
)
BACKUP_SUFFIX=".real"
# ── Colors ────────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
# ── Find LS binary ───────────────────────────────────────────────────────────
find_ls_binary() {
# Method 1: Find from running process via /proc
if [[ -d /proc ]]; then
for pid_dir in /proc/[0-9]*; do
local exe_target
exe_target="$(readlink "${pid_dir}/exe" 2>/dev/null)" || continue
# Strip " (deleted)" suffix that appears when the binary was unlinked
exe_target="${exe_target% (deleted)}"
if [[ "$exe_target" == *language_server_linux* ]] || \
[[ "$exe_target" == *antigravity-language-server* ]]; then
# FIX: If the running process is the backup (.real), strip the suffix
# so we return the path to the base binary name.
echo "${exe_target%$BACKUP_SUFFIX}"
return 0
fi
done
fi
# Method 2: Fallback — scan known directories for common binary names
local bin_names=("language_server_linux_x64" "language_server_linux_arm64" "antigravity-language-server")
for dir_pattern in "${LS_FALLBACK_DIRS[@]}"; do
for dir in $dir_pattern; do
[[ -d "$dir" ]] || continue
for name in "${bin_names[@]}"; do
local path="${dir}/${name}"
if [[ -f "$path" || -f "${path}${BACKUP_SUFFIX}" ]]; then
echo "$path"
return 0
fi
done
done
done
return 1
}
# ── Install ──────────────────────────────────────────────────────────────────
cmd_install() {
# Find the LS binary first (quiet, just to check permissions)
local ls_path
ls_path=$(find_ls_binary) || ls_path="${1:-}"
# Allow override
if [[ -n "${1:-}" ]]; then
ls_path="$1"
fi
# Check permissions upfront — re-exec with sudo before doing anything
if [[ -n "$ls_path" ]]; then
local ls_dir
ls_dir="$(dirname "$ls_path")"
if [[ ! -w "$ls_dir" ]] && [[ "$EUID" -ne 0 ]]; then
echo -e " ${RED}${NC} ${ls_dir} requires elevated permissions"
echo -e " run: sudo $0 install ${1:-}"
exit 1
fi
fi
echo -e "${BOLD}${CYAN}Antigravity MITM Wrapper Installer${NC}"
echo -e "───────────────────────────────────"
echo ""
# Find the LS binary (for real this time, with output)
if [[ -z "$ls_path" ]]; then
echo -e " ${RED}${NC} Could not find Antigravity language server binary."
echo -e " No LS process found in /proc, and fallback paths didn't match."
echo ""
echo -e " Set the path manually:"
echo -e " $0 install /path/to/language_server_linux_x64"
exit 1
fi
echo -e " ${GREEN}${NC} Found LS: ${ls_path}"
local real_path="${ls_path}${BACKUP_SUFFIX}"
local wrapper_dir
wrapper_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
local wrapper_src="${wrapper_dir}/mitm-wrapper.sh"
# Verify the binary exists and is not already wrapped
if [[ -f "$real_path" ]]; then
echo -e " ${YELLOW}!${NC} Already installed (backup exists at ${real_path})"
echo -e " Run '$0 uninstall' first to reinstall."
exit 0
fi
if [[ ! -f "$ls_path" ]]; then
echo -e " ${RED}${NC} Binary not found: ${ls_path}"
exit 1
fi
# Verify it's a real binary, not already our wrapper
if head -c 100 "$ls_path" | grep -q 'ANTIGRAVITY_MITM_PORT'; then
echo -e " ${YELLOW}!${NC} Already wrapped (script detected). Run '$0 uninstall' first."
exit 0
fi
# Check CA cert
if [[ ! -f "$CA_PATH" ]]; then
echo -e " ${YELLOW}!${NC} CA cert not found at ${CA_PATH}"
echo -e " Start the proxy first to generate it."
echo -e " Continuing install anyway..."
else
echo -e " ${GREEN}${NC} CA cert: ${CA_PATH}"
fi
# Back up the real binary
cp -p "$ls_path" "$real_path"
echo -e " ${GREEN}${NC} Backed up real binary to ${real_path}"
# Remove the original before writing (avoids "Text file busy" if LS is running)
rm -f "$ls_path"
# Create the wrapper script in-place
tee "$ls_path" > /dev/null << 'WRAPPER_EOF'
#!/usr/bin/env bash
# Antigravity MITM LS Wrapper — auto-generated, do not edit.
# The LS is a Go binary — it reads HTTPS_PROXY and SSL_CERT_FILE (not NODE_EXTRA_CA_CERTS).
# Go's gRPC library also reads GRPC_DEFAULT_SSL_ROOTS_FILE_PATH for root certs.
# We build a combined CA bundle (system CAs + MITM CA) and inject it.
REAL_BINARY="${BASH_SOURCE[0]}.real"
if [[ ! -f "$REAL_BINARY" ]]; then
echo "ERROR: Real LS binary not found at $REAL_BINARY" >&2
echo "Run 'mitm-wrapper.sh uninstall' and reinstall." >&2
exit 1
fi
# Inject MITM proxy (don't override if already set)
export HTTPS_PROXY="${HTTPS_PROXY:-http://127.0.0.1:__MITM_PORT__}"
# Build combined CA bundle: system CAs + MITM CA
MITM_CA="__CA_PATH__"
COMBINED_CA="/tmp/antigravity-mitm-combined-ca.pem"
if [[ -f "$MITM_CA" ]]; then
# Find system CA bundle
SYS_CA=""
for candidate in /etc/ssl/certs/ca-certificates.crt /etc/pki/tls/certs/ca-bundle.crt /etc/ssl/cert.pem; do
if [[ -f "$candidate" ]]; then
SYS_CA="$candidate"
break
fi
done
if [[ -n "$SYS_CA" ]]; then
cat "$SYS_CA" "$MITM_CA" > "$COMBINED_CA" 2>/dev/null
export SSL_CERT_FILE="$COMBINED_CA"
# Go's gRPC library may use this instead of SSL_CERT_FILE
export GRPC_DEFAULT_SSL_ROOTS_FILE_PATH="$COMBINED_CA"
fi
fi
exec "$REAL_BINARY" "$@"
WRAPPER_EOF
# Substitute actual values
sed -i "s|__MITM_PORT__|${MITM_PORT}|g" "$ls_path"
sed -i "s|__CA_PATH__|${CA_PATH}|g" "$ls_path"
# Make executable
chmod +x "$ls_path"
echo -e " ${GREEN}${NC} Wrapper installed at ${ls_path}"
echo ""
echo -e " ${BOLD}How it works:${NC}"
echo -e " When Antigravity starts the LS, the wrapper will:"
echo -e " 1. Set ${CYAN}HTTPS_PROXY${NC}=http://127.0.0.1:${MITM_PORT}"
echo -e " 2. Build combined CA bundle (system + MITM) at /tmp/antigravity-mitm-combined-ca.pem"
echo -e " 3. Set ${CYAN}SSL_CERT_FILE${NC} to the combined bundle"
echo -e " 4. Exec the real LS binary with all original args"
echo ""
echo -e " ${YELLOW}Note:${NC} Restart Antigravity for the wrapper to take effect."
echo ""
}
# ── Uninstall ────────────────────────────────────────────────────────────────
cmd_uninstall() {
# Check permissions upfront
local ls_path
ls_path=$(find_ls_binary) || true
if [[ -n "$ls_path" ]] && [[ ! -w "$(dirname "$ls_path")" ]] && [[ "$EUID" -ne 0 ]]; then
echo -e " ${RED}${NC} $(dirname "$ls_path") requires elevated permissions"
echo -e " run: sudo $0 uninstall"
exit 1
fi
echo -e "${BOLD}${CYAN}Antigravity MITM Wrapper Uninstaller${NC}"
echo -e "─────────────────────────────────────"
echo ""
if [[ -n "$ls_path" ]]; then
local real_path="${ls_path}${BACKUP_SUFFIX}"
if [[ -f "$real_path" ]]; then
mv -f "$real_path" "$ls_path"
echo -e " ${GREEN}${NC} Restored real binary at ${ls_path}"
else
echo -e " ${YELLOW}!${NC} No backup found at ${real_path}"
echo -e " The LS binary may not be wrapped."
fi
else
echo -e " ${RED}${NC} Could not find Antigravity language server binary."
fi
echo ""
echo -e " ${YELLOW}Note:${NC} Restart Antigravity for the change to take effect."
echo ""
}
# ── Status ───────────────────────────────────────────────────────────────────
cmd_status() {
echo -e "${BOLD}${CYAN}Antigravity MITM Wrapper Status${NC}"
echo -e "────────────────────────────────"
echo ""
local ls_path
if ls_path=$(find_ls_binary); then
echo -e " ${GREEN}${NC} LS binary: ${ls_path}"
local real_path="${ls_path}${BACKUP_SUFFIX}"
if [[ -f "$real_path" ]]; then
echo -e " ${GREEN}${NC} Wrapper: ${BOLD}installed${NC}"
echo -e " ${GREEN}${NC} Real binary: ${real_path}"
# Check if wrapper is valid
if head -c 200 "$ls_path" | grep -q 'MITM LS Wrapper'; then
echo -e " ${GREEN}${NC} Wrapper script: valid"
else
echo -e " ${RED}${NC} Wrapper script: ${BOLD}corrupted or replaced${NC}"
fi
else
echo -e " ${YELLOW}${NC} Wrapper: ${BOLD}not installed${NC}"
fi
else
echo -e " ${RED}${NC} LS binary: not found"
fi
# Check CA cert
if [[ -f "$CA_PATH" ]]; then
echo -e " ${GREEN}${NC} CA cert: ${CA_PATH}"
else
echo -e " ${RED}${NC} CA cert: not found (start proxy first)"
fi
# Check MITM port
if ss -tlnp 2>/dev/null | grep -q ":${MITM_PORT} "; then
echo -e " ${GREEN}${NC} MITM proxy: listening on :${MITM_PORT}"
else
echo -e " ${YELLOW}${NC} MITM proxy: not running on :${MITM_PORT}"
fi
echo ""
}
# ── Main ─────────────────────────────────────────────────────────────────────
case "${1:-}" in
install)
shift
cmd_install "${1:-}"
;;
uninstall)
cmd_uninstall
;;
status)
cmd_status
;;
-h|--help)
echo "Usage: $0 {install|uninstall|status}"
echo ""
echo "Commands:"
echo " install [path] Install MITM wrapper (auto-detect or specify path)"
echo " uninstall Restore original LS binary"
echo " status Show wrapper installation status"
echo ""
echo "Environment:"
echo " ANTIGRAVITY_MITM_PORT MITM proxy port (default: 8742)"
;;
*)
echo "Usage: $0 {install|uninstall|status}"
exit 1
;;
esac

View File

@@ -1,475 +0,0 @@
#!/usr/bin/env python3
"""
Parse Go GODEBUG=http2debug=2 output into a clean, readable snapshot.
Usage:
python3 parse-snapshot.py < raw-http2-dump.log
python3 parse-snapshot.py /path/to/logfile
"""
import sys
import re
import json
import gzip
from collections import defaultdict
from io import BytesIO
# ── Colors ────────────────────────────────────────────────────────────────────
BOLD = "\033[1m"
DIM = "\033[2m"
RED = "\033[91m"
GREEN = "\033[92m"
YELLOW = "\033[93m"
CYAN = "\033[96m"
MAGENTA = "\033[95m"
NC = "\033[0m"
# ── Regexes ───────────────────────────────────────────────────────────────────
RE_ENCODING_HEADER = re.compile(
r'http2: Transport encoding header "([^"]+)" = "([^"]*)"'
)
RE_DECODED_HEADER = re.compile(
r'http2: decoded hpack field header field "([^"]+)" = "([^"]*)"'
)
RE_SERVER_ENCODING = re.compile(
r'http2: server encoding header "([^"]+)" = "([^"]*)"'
)
RE_WROTE_DATA = re.compile(
r'http2: Framer [^:]+: wrote DATA flags=(\S+) stream=(\d+) len=(\d+) data="(.*?)"'
)
RE_READ_DATA = re.compile(
r'http2: Framer [^:]+: read DATA flags=(\S+) stream=(\d+) len=(\d+) data="(.*?)"'
)
RE_TRANSPORT_CONN = re.compile(
r'http2: Transport creating client conn [^ ]+ to (.+)'
)
RE_SERVER_READ_DATA = re.compile(
r'http2: server read frame DATA flags=(\S+) stream=(\d+) len=(\d+) data="(.*?)"'
)
RE_WROTE_HEADERS = re.compile(
r'http2: Framer [^:]+: wrote HEADERS flags=(\S+) stream=(\d+)'
)
RE_TIMESTAMP = re.compile(r'^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2})')
RE_LS_LOG = re.compile(r'^[IWE]\d{4} ')
RE_MAXPROCS = re.compile(r'^.*maxprocs:')
RE_BYTES_OMITTED = re.compile(r'\((\d+) bytes omitted\)$')
# Known domain purposes
DOMAIN_INFO = {
"antigravity-unleash.goog": ("Feature Flags", "Unleash SDK — controls A/B tests, feature rollouts"),
"daily-cloudcode-pa.googleapis.com": ("LLM API (gRPC)", "Primary Gemini/Claude API endpoint"),
"cloudcode-pa.googleapis.com": ("LLM API (gRPC)", "Production Gemini/Claude API endpoint"),
"api.anthropic.com": ("Claude API", "Direct Anthropic API calls"),
"lh3.googleusercontent.com": ("Profile Picture", "User avatar image"),
"play.googleapis.com": ("Telemetry", "Google Play telemetry/logging"),
"firebaseinstallations.googleapis.com": ("Firebase", "Firebase installation tracking"),
"oauth2.googleapis.com": ("OAuth", "Token refresh/exchange"),
"speech.googleapis.com": ("Speech", "Voice input processing"),
"modelarmor.googleapis.com": ("Safety", "Content safety/filtering"),
}
class Request:
def __init__(self):
self.method = ""
self.path = ""
self.authority = ""
self.scheme = ""
self.headers = {}
self.data = b""
self.data_len = 0
self.stream_id = None
self.timestamp = ""
self.direction = "outgoing" # outgoing = LS→upstream, incoming = LS←upstream
class Snapshot:
def __init__(self):
self.connections = [] # (timestamp, target)
self.requests = [] # list of Request
self.responses = defaultdict(lambda: {"headers": {}, "data": b"", "data_len": 0})
self.ls_logs = []
def parse(self, lines):
current_headers = {}
current_direction = "outgoing"
current_stream = None
for line in lines:
line = line.rstrip()
# Skip empty
if not line:
continue
# LS process logs
if RE_LS_LOG.match(line) or RE_MAXPROCS.match(line):
self.ls_logs.append(line)
continue
# New connection
m = RE_TRANSPORT_CONN.search(line)
if m:
ts = ""
ts_m = RE_TIMESTAMP.match(line)
if ts_m:
ts = ts_m.group(1)
self.connections.append((ts, m.group(1)))
continue
# Outgoing headers (Transport encoding = LS sending to upstream)
m = RE_ENCODING_HEADER.search(line)
if m:
key, val = m.group(1), m.group(2)
if key == ":method":
# New request starting
if current_headers.get(":path"):
self._finalize_request(current_headers, "outgoing", line)
current_headers = {}
current_direction = "outgoing"
current_headers[key] = val
ts_m = RE_TIMESTAMP.match(line)
if ts_m and "timestamp" not in current_headers:
current_headers["timestamp"] = ts_m.group(1)
continue
# Incoming headers (decoded hpack = upstream responding, OR server receiving)
m = RE_DECODED_HEADER.search(line)
if m:
key, val = m.group(1), m.group(2)
if key == ":authority" and "server read frame" not in line:
# This is a request received by our LS
if current_headers.get(":path"):
self._finalize_request(current_headers, current_direction, line)
current_headers = {}
current_direction = "incoming"
current_headers[key] = val
continue
# Server encoding (our LS responding)
m = RE_SERVER_ENCODING.search(line)
if m:
continue # Skip server response headers for now
# Headers frame written (triggers finalization)
m = RE_WROTE_HEADERS.search(line)
if m:
current_stream = m.group(2)
if current_headers.get(":path") or current_headers.get(":method"):
req = self._finalize_request(current_headers, current_direction, line)
if req:
req.stream_id = current_stream
current_headers = {}
continue
# Data frames (wrote = LS sending, read = LS receiving)
for pattern, direction in [
(RE_WROTE_DATA, "sent"),
(RE_READ_DATA, "received"),
(RE_SERVER_READ_DATA, "server_received"),
]:
m = pattern.search(line)
if m:
flags, stream, length, data_str = (
m.group(1),
m.group(2),
int(m.group(3)),
m.group(4),
)
# Find matching request by stream
for req in reversed(self.requests):
if req.stream_id == stream:
raw = self._decode_data_str(data_str, line)
if direction == "sent" or direction == "server_received":
req.data += raw
req.data_len = max(req.data_len, length)
break
# Also check omitted bytes
om = RE_BYTES_OMITTED.search(line)
if om:
pass # length already captured
break
# Finalize any remaining headers
if current_headers.get(":path") or current_headers.get(":method"):
self._finalize_request(current_headers, current_direction, "")
def _finalize_request(self, headers, direction, _line):
req = Request()
req.method = headers.pop(":method", "GET")
req.path = headers.pop(":path", "/")
req.authority = headers.pop(":authority", "")
req.scheme = headers.pop(":scheme", "https")
req.timestamp = headers.pop("timestamp", "")
req.direction = direction
req.headers = {k: v for k, v in headers.items() if not k.startswith(":")}
self.requests.append(req)
return req
def _decode_data_str(self, s, full_line):
"""Decode escaped string from GODEBUG output back to bytes."""
try:
# Handle Go's escaped bytes
result = bytearray()
i = 0
while i < len(s):
if s[i] == "\\" and i + 1 < len(s):
if s[i + 1] == "x" and i + 3 < len(s):
result.append(int(s[i + 2 : i + 4], 16))
i += 4
elif s[i + 1] == "n":
result.append(10)
i += 2
elif s[i + 1] == "r":
result.append(13)
i += 2
elif s[i + 1] == "t":
result.append(9)
i += 2
elif s[i + 1] == "\\":
result.append(92)
i += 2
elif s[i + 1] == '"':
result.append(34)
i += 2
else:
result.append(ord(s[i]))
i += 1
else:
result.append(ord(s[i]))
i += 1
return bytes(result)
except Exception:
return s.encode("utf-8", errors="replace")
def render(self):
out = []
# Header
out.append(f"\n{BOLD}{CYAN}{'' * 70}{NC}")
out.append(f"{BOLD}{CYAN} STANDALONE LS TRAFFIC SNAPSHOT{NC}")
out.append(f"{BOLD}{CYAN}{'' * 70}{NC}\n")
# LS Logs
if self.ls_logs:
out.append(f"{BOLD}▸ Language Server Logs{NC}")
out.append(f"{DIM}{'' * 60}{NC}")
for log in self.ls_logs:
out.append(f" {DIM}{log}{NC}")
out.append("")
# Connections
if self.connections:
out.append(f"{BOLD}▸ Outbound Connections{NC}")
out.append(f"{DIM}{'' * 60}{NC}")
for ts, target in self.connections:
domain = target.split(":")[0] if ":" in target else target
info = DOMAIN_INFO.get(domain, ("Unknown", ""))
out.append(
f" {GREEN}{NC} {BOLD}{target}{NC} {DIM}({info[0]}){NC}"
)
if info[1]:
out.append(f" {DIM}{info[1]}{NC}")
out.append("")
# Group requests by domain
by_domain = defaultdict(list)
for req in self.requests:
by_domain[req.authority].append(req)
# Render each domain's requests
for domain, reqs in by_domain.items():
if domain.startswith("127.0.0.1"):
label = "Local (our requests to LS)"
color = DIM
else:
info = DOMAIN_INFO.get(domain, ("External", ""))
label = info[0]
color = YELLOW if "API" in info[0] else CYAN
out.append(f"{BOLD}{'' * 70}{NC}")
out.append(f"{BOLD}{color} {domain}{NC} {DIM}{label}{NC}")
out.append(f"{BOLD}{'' * 70}{NC}")
for i, req in enumerate(reqs):
arrow = "" if req.direction == "outgoing" else ""
method_color = GREEN if req.method == "GET" else YELLOW
out.append(f"\n {BOLD}{arrow} {method_color}{req.method}{NC} {req.path}")
# Important headers
interesting = [
"authorization",
"content-type",
"user-agent",
"unleash-appname",
"unleash-instanceid",
"unleash-sdk",
"x-goog-api-key",
"x-goog-api-client",
"grpc-encoding",
"te",
]
shown = False
for key in interesting:
if key in req.headers:
val = req.headers[key]
# Mask tokens partially
if key == "authorization" and len(val) > 30:
if val.startswith("Bearer "):
val = f"Bearer {val[7:20]}...{val[-10:]}"
elif len(val) > 40:
val = f"{val[:30]}...{val[-10:]}"
out.append(f" {DIM}{key}:{NC} {val}")
shown = True
# All other headers (collapsed)
other = {
k: v
for k, v in req.headers.items()
if k not in interesting and not k.startswith(":")
}
if other:
if not shown:
out.append(f" {DIM}Headers:{NC}")
for k, v in other.items():
out.append(f" {DIM}{k}:{NC} {v}")
# Body
if req.data:
out.append(self._render_body(req.data, req.data_len))
out.append("")
return "\n".join(out)
def _render_body(self, data, total_len):
"""Render body data in the most readable format possible."""
lines = []
# Try JSON
try:
text = data.decode("utf-8")
obj = json.loads(text)
pretty = json.dumps(obj, indent=2, ensure_ascii=False)
lines.append(f" {BOLD}Body ({len(data)} bytes, JSON):{NC}")
for l in pretty.split("\n")[:30]:
lines.append(f" {GREEN}{l}{NC}")
if len(pretty.split("\n")) > 30:
lines.append(f" {DIM}... ({len(pretty.split(chr(10))) - 30} more lines){NC}")
return "\n".join(lines)
except (json.JSONDecodeError, UnicodeDecodeError):
pass
# Try gzip
if data[:2] == b"\x1f\x8b":
try:
decompressed = gzip.decompress(data)
try:
text = decompressed.decode("utf-8")
try:
obj = json.loads(text)
pretty = json.dumps(obj, indent=2, ensure_ascii=False)
lines.append(
f" {BOLD}Body ({len(data)} bytes gzip → {len(decompressed)} bytes, JSON):{NC}"
)
for l in pretty.split("\n")[:50]:
lines.append(f" {GREEN}{l}{NC}")
if len(pretty.split("\n")) > 50:
lines.append(
f" {DIM}... ({len(pretty.split(chr(10))) - 50} more lines){NC}"
)
return "\n".join(lines)
except json.JSONDecodeError:
lines.append(
f" {BOLD}Body ({len(data)} bytes gzip → {len(decompressed)} bytes, text):{NC}"
)
for l in text.split("\n")[:20]:
lines.append(f" {l[:200]}")
return "\n".join(lines)
except UnicodeDecodeError:
lines.append(
f" {BOLD}Body ({len(data)} bytes gzip → {len(decompressed)} bytes, binary):{NC}"
)
lines.append(f" {DIM}{self._extract_strings(decompressed)}{NC}")
return "\n".join(lines)
except Exception:
pass
# Try protobuf (extract readable strings)
if data[:1] in (b"\x08", b"\x0a", b"\x10", b"\x12", b"\x18", b"\x1a", b"\x20", b"\x22"):
strings = self._extract_strings(data)
if strings:
lines.append(f" {BOLD}Body ({total_len} bytes, protobuf):{NC}")
lines.append(f" {DIM}Extracted strings:{NC}")
for s in strings.split(" | ")[:20]:
s = s.strip()
if len(s) > 3:
lines.append(f" {MAGENTA}{s}{NC}")
return "\n".join(lines)
# Try plain text
try:
text = data.decode("utf-8")
lines.append(f" {BOLD}Body ({len(data)} bytes, text):{NC}")
for l in text.split("\n")[:10]:
lines.append(f" {l[:200]}")
return "\n".join(lines)
except UnicodeDecodeError:
pass
# PNG
if data[:4] == b"\x89PNG":
lines.append(f" {BOLD}Body ({total_len} bytes, PNG image){NC}")
return "\n".join(lines)
# Binary fallback
lines.append(f" {BOLD}Body ({total_len} bytes, binary):{NC}")
strings = self._extract_strings(data)
if strings:
lines.append(f" {DIM}Extracted strings:{NC}")
for s in strings.split(" | ")[:15]:
s = s.strip()
if len(s) > 3:
lines.append(f" {MAGENTA}{s}{NC}")
else:
lines.append(f" {DIM}(no readable strings){NC}")
return "\n".join(lines)
def _extract_strings(self, data, min_len=4):
"""Extract printable ASCII strings from binary data."""
strings = []
current = bytearray()
for b in data:
if 32 <= b <= 126:
current.append(b)
else:
if len(current) >= min_len:
strings.append(current.decode("ascii"))
current = bytearray()
if len(current) >= min_len:
strings.append(current.decode("ascii"))
# Deduplicate while preserving order
seen = set()
unique = []
for s in strings:
if s not in seen:
seen.add(s)
unique.append(s)
return " | ".join(unique[:30])
def main():
if len(sys.argv) > 1:
with open(sys.argv[1]) as f:
lines = f.readlines()
else:
lines = sys.stdin.readlines()
snap = Snapshot()
snap.parse(lines)
print(snap.render())
if __name__ == "__main__":
main()

130
scripts/proxyctl Executable file
View File

@@ -0,0 +1,130 @@
#!/usr/bin/env bash
# proxyctl — manage the antigravity proxy daemon
set -euo pipefail
SERVICE="antigravity-proxy"
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
PORT="${PROXY_PORT:-8741}"
BASE_URL="http://localhost:${PORT}"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
DIM='\033[2m'
BOLD='\033[1m'
NC='\033[0m'
usage() {
echo -e "${BOLD}proxyctl${NC} — antigravity proxy daemon manager"
echo ""
echo -e " ${CYAN}start${NC} Start the proxy daemon"
echo -e " ${CYAN}stop${NC} Stop the proxy daemon"
echo -e " ${CYAN}restart${NC} Rebuild + restart"
echo -e " ${CYAN}rebuild${NC} Build release binary only"
echo -e " ${CYAN}status${NC} Service status + quota + usage"
echo -e " ${CYAN}logs${NC} [N] Tail last N lines (default 30) + follow"
echo -e " ${CYAN}logs-all${NC} Full log dump (no follow)"
echo -e " ${CYAN}test${NC} [msg] Quick test request (gemini-3-flash)"
echo -e " ${CYAN}health${NC} Health check"
echo ""
}
do_build() {
echo -e "${YELLOW}Building release binary...${NC}"
cd "$PROJECT_DIR"
cargo build --release 2>&1
echo -e "${GREEN}Build complete.${NC}"
}
do_start() {
systemctl --user daemon-reload
systemctl --user start "$SERVICE"
echo -e "${GREEN}Started.${NC} Waiting for ready..."
# Wait up to 10s for health
for i in $(seq 1 20); do
if curl -sf "${BASE_URL}/health" >/dev/null 2>&1; then
echo -e "${GREEN}Proxy is up on port ${PORT}.${NC}"
return 0
fi
sleep 0.5
done
echo -e "${RED}Proxy didn't become healthy in 10s. Check logs:${NC}"
journalctl --user -u "$SERVICE" --no-pager -n 20
return 1
}
do_stop() {
systemctl --user stop "$SERVICE" 2>/dev/null || true
echo -e "${YELLOW}Stopped.${NC}"
}
do_restart() {
echo -e "${YELLOW}Stopping...${NC}"
do_stop
do_build
do_start
}
do_status() {
echo -e "${BOLD}── Service ──${NC}"
systemctl --user status "$SERVICE" --no-pager 2>/dev/null | head -6 || echo -e "${RED}Not running${NC}"
echo ""
# Health check
if ! curl -sf "${BASE_URL}/health" >/dev/null 2>&1; then
echo -e "${RED}Proxy is not responding on port ${PORT}.${NC}"
return 1
fi
echo -e "${BOLD}── Quota ──${NC}"
curl -sf "${BASE_URL}/v1/quota" 2>/dev/null | jq '.' 2>/dev/null || echo -e "${DIM}(no quota data)${NC}"
echo ""
echo -e "${BOLD}── Usage ──${NC}"
curl -sf "${BASE_URL}/v1/usage" 2>/dev/null | jq '.' 2>/dev/null || echo -e "${DIM}(no usage data)${NC}"
echo ""
echo -e "${BOLD}── Sessions ──${NC}"
curl -sf "${BASE_URL}/v1/sessions" 2>/dev/null | jq '.' 2>/dev/null || echo -e "${DIM}(no sessions)${NC}"
}
do_logs() {
local lines="${1:-30}"
journalctl --user -u "$SERVICE" --no-pager -n "$lines" -f
}
do_logs_all() {
journalctl --user -u "$SERVICE" --no-pager
}
do_test() {
local msg="${1:-Say hello in exactly 3 words}"
echo -e "${CYAN}Testing:${NC} ${msg}"
curl -sf "${BASE_URL}/v1/responses" \
-H "Content-Type: application/json" \
-d "{
\"model\": \"gemini-3-flash\",
\"input\": \"${msg}\",
\"stream\": false,
\"timeout\": 30
}" | jq '.'
}
do_health() {
curl -sf "${BASE_URL}/health" | jq '.' 2>/dev/null && echo -e "${GREEN}Healthy${NC}" || echo -e "${RED}Not responding${NC}"
}
# ── Main ──
case "${1:-}" in
start) do_start ;;
stop) do_stop ;;
restart) do_restart ;;
rebuild) do_build ;;
status) do_status ;;
logs) do_logs "${2:-30}" ;;
logs-all) do_logs_all ;;
test) do_test "${2:-}" ;;
health) do_health ;;
*) usage ;;
esac

View File

@@ -1,277 +0,0 @@
#!/usr/bin/env bash
# ╔═══════════════════════════════════════════════════════════════════════════╗
# ║ Standalone Language Server Launcher ║
# ║ ║
# ║ Launches an isolated LS instance that: ║
# ║ - Shares OAuth via the main app's extension server ║
# ║ - Has its own HTTPS port, data dir, and cascades ║
# ║ - Optionally routes traffic through our MITM proxy ║
# ║ - Can capture a clean traffic snapshot ║
# ║ ║
# ║ Usage: ║
# ║ ./standalone-ls.sh # Launch, test, exit ║
# ║ ./standalone-ls.sh --fg # Foreground (stay alive) ║
# ║ ./standalone-ls.sh --mitm # Route through MITM proxy ║
# ║ ./standalone-ls.sh --snapshot # Capture clean traffic dump ║
# ║ ./standalone-ls.sh --snapshot --prompt "Say hello" ║
# ╚═══════════════════════════════════════════════════════════════════════════╝
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ── Defaults ──────────────────────────────────────────────────────────────────
LS_BIN="/usr/share/antigravity/resources/app/extensions/antigravity/bin/language_server_linux_x64"
HTTPS_PORT="42200"
DATA_DIR="/tmp/antigravity-standalone"
FOREGROUND=false
USE_MITM=false
SNAPSHOT=false
TIMEOUT=15
PROMPT=""
MODEL="MODEL_PLACEHOLDER_M3"
# ── Parse args ────────────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--port) HTTPS_PORT="$2"; shift 2 ;;
--mitm) USE_MITM=true; shift ;;
--fg) FOREGROUND=true; shift ;;
--timeout) TIMEOUT="$2"; shift 2 ;;
--snapshot) SNAPSHOT=true; TIMEOUT=30; shift ;;
--prompt) PROMPT="$2"; shift 2 ;;
--model) MODEL="$2"; shift 2 ;;
-h|--help)
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " --port PORT HTTPS port for standalone LS (default: 42200)"
echo " --mitm Route traffic through MITM proxy"
echo " --fg Run in foreground (stay alive)"
echo " --timeout SECS Background mode timeout (default: 15)"
echo " --snapshot Capture clean traffic snapshot"
echo " --prompt TEXT Prompt to send (snapshot mode)"
echo " --model MODEL Model alias (default: MODEL_PLACEHOLDER_M3)"
exit 0 ;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
# ── Discover main LS config ──────────────────────────────────────────────────
MAIN_PID=$(pgrep -f 'language_server_linux_x64' | head -1 || true)
if [[ -z "$MAIN_PID" ]]; then
echo "[-] No main LS process found. Main Antigravity must be running."
exit 1
fi
MAIN_CSRF=$(tr '\0' '\n' < /proc/"$MAIN_PID"/cmdline | grep -A1 'csrf_token' | tail -1)
EXT_PORT=$(tr '\0' '\n' < /proc/"$MAIN_PID"/cmdline | grep -A1 'extension_server_port' | tail -1)
echo "[*] Main LS PID: $MAIN_PID"
echo "[*] CSRF: $MAIN_CSRF"
echo "[*] Extension server: $EXT_PORT"
# ── Build protobuf metadata for stdin ─────────────────────────────────────────
TS=$(date +%s)
METADATA=$(python3 -c "
import sys
def v(n):
r = bytearray()
while n > 0x7f:
r.append((n & 0x7f) | 0x80)
n >>= 7
r.append(n & 0x7f)
return bytes(r)
def s(f, val):
t = v((f << 3) | 2)
d = val.encode()
return t + v(len(d)) + d
buf = bytearray()
buf += s(1, 'standalone-api-key-$TS')
buf += s(3, 'antigravity')
buf += s(4, '1.15.8')
buf += s(5, '1.16.39')
buf += s(6, 'en_US')
buf += s(10, 'standalone-session-$TS')
buf += s(11, 'antigravity')
sys.stdout.buffer.write(bytes(buf))
" | base64)
# ── Setup data directory ──────────────────────────────────────────────────────
mkdir -p "$DATA_DIR/.gemini"
# ── MITM environment ─────────────────────────────────────────────────────────
MITM_ENV=()
if $USE_MITM; then
REAL_HOME="${SUDO_USER:+$(getent passwd "$SUDO_USER" | cut -d: -f6)}"
REAL_HOME="${REAL_HOME:-$HOME}"
MITM_PORT_FILE="${REAL_HOME}/.config/antigravity-proxy/mitm-port"
CA_PATH="${REAL_HOME}/.config/antigravity-proxy/mitm-ca.pem"
if [[ -f "$MITM_PORT_FILE" ]]; then
MITM_PORT=$(cat "$MITM_PORT_FILE")
else
MITM_PORT="8742"
fi
if [[ ! -f "$CA_PATH" ]]; then
echo "[-] MITM CA cert not found at $CA_PATH"
echo " Start the proxy first to generate it."
exit 1
fi
COMBINED_CA="/tmp/antigravity-mitm-combined-ca.pem"
SYS_CA=""
for candidate in /etc/ssl/certs/ca-certificates.crt /etc/pki/tls/certs/ca-bundle.crt /etc/ssl/cert.pem; do
if [[ -f "$candidate" ]]; then SYS_CA="$candidate"; break; fi
done
if [[ -n "$SYS_CA" ]]; then
cat "$SYS_CA" "$CA_PATH" > "$COMBINED_CA"
else
echo "[-] No system CA bundle found"
exit 1
fi
MITM_ENV=(
"HTTPS_PROXY=http://127.0.0.1:${MITM_PORT}"
"SSL_CERT_FILE=${COMBINED_CA}"
"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH=${COMBINED_CA}"
)
echo "[*] MITM: enabled (port $MITM_PORT)"
else
echo "[*] MITM: disabled (use --mitm to enable)"
fi
# ── LS args ───────────────────────────────────────────────────────────────────
LS_ARGS=(
-enable_lsp
-extension_server_port "$EXT_PORT"
-csrf_token "$MAIN_CSRF"
-server_port "$HTTPS_PORT"
-workspace_id "standalone_$TS"
-cloud_code_endpoint "https://daily-cloudcode-pa.googleapis.com"
-app_data_dir "antigravity-standalone"
-gemini_dir "$DATA_DIR/.gemini"
)
# ── Extra env for snapshot mode ───────────────────────────────────────────────
EXTRA_ENV=()
if $SNAPSHOT; then
EXTRA_ENV=("GODEBUG=http2debug=2")
echo "[*] Snapshot: enabled (HTTP/2 debug tracing)"
fi
# ── Banner ────────────────────────────────────────────────────────────────────
echo ""
echo "========================================="
echo " Standalone LS"
echo " Port: $HTTPS_PORT (HTTPS)"
echo " Data: $DATA_DIR"
echo " Mode: $($FOREGROUND && echo "foreground" || echo "background ($TIMEOUT s)")"
echo "========================================="
echo ""
# ── Foreground mode ───────────────────────────────────────────────────────────
if $FOREGROUND; then
echo "$METADATA" | base64 -d | \
env "${MITM_ENV[@]+"${MITM_ENV[@]}"}" \
"${EXTRA_ENV[@]+"${EXTRA_ENV[@]}"}" \
ANTIGRAVITY_EDITOR_APP_ROOT="/usr/share/antigravity/resources/app" \
exec "$LS_BIN" "${LS_ARGS[@]}"
exit 0
fi
# ── Background mode ──────────────────────────────────────────────────────────
LOG="/tmp/standalone-ls.log"
rm -f "$LOG"
echo "$METADATA" | base64 -d | \
env "${MITM_ENV[@]+"${MITM_ENV[@]}"}" \
"${EXTRA_ENV[@]+"${EXTRA_ENV[@]}"}" \
ANTIGRAVITY_EDITOR_APP_ROOT="/usr/share/antigravity/resources/app" \
timeout "$TIMEOUT" "$LS_BIN" "${LS_ARGS[@]}" \
> "$LOG" 2>&1 &
LS_PID=$!
echo "[*] PID: $LS_PID"
# Wait for init
for i in $(seq 1 5); do
sleep 1
if ! kill -0 "$LS_PID" 2>/dev/null; then
echo "[-] LS died after ${i}s"
echo "=== LOGS ==="
cat "$LOG"
exit 1
fi
done
echo "[+] LS alive and initialized"
# ── Snapshot mode: send a prompt and capture traffic ──────────────────────────
if $SNAPSHOT; then
if [[ -z "$PROMPT" ]]; then
PROMPT="Say exactly: Hello standalone world"
fi
echo ""
echo "[*] Sending cascade: \"$PROMPT\""
CASCADE_ID=$(curl -sk --max-time 10 \
"https://127.0.0.1:${HTTPS_PORT}/exa.language_server_pb.LanguageServerService/StartCascade" \
-H "Content-Type: application/json" \
-H "x-codeium-csrf-token: $MAIN_CSRF" \
-H "Origin: vscode-file://vscode-app" \
-d "{
\"prompt\": \"$PROMPT\",
\"modelOrAlias\": {\"model\": \"$MODEL\"},
\"workspaceRootPaths\": [\"$DATA_DIR\"]
}" 2>/dev/null | python3 -c "import json,sys; print(json.load(sys.stdin).get('cascadeId',''))" 2>/dev/null || true)
echo "[*] Cascade: $CASCADE_ID"
echo "[*] Waiting 15s for upstream API calls..."
sleep 15
# Kill LS to flush logs
kill "$LS_PID" 2>/dev/null
wait "$LS_PID" 2>/dev/null || true
# Parse and display
echo ""
python3 "$SCRIPT_DIR/parse-snapshot.py" "$LOG"
# Also save raw log
SNAPSHOT_FILE="/tmp/standalone-snapshot-$(date +%Y%m%d-%H%M%S).log"
cp "$LOG" "$SNAPSHOT_FILE"
echo ""
echo "[*] Raw log saved to: $SNAPSHOT_FILE"
exit 0
fi
# ── Normal mode: test and report ──────────────────────────────────────────────
echo ""
echo "=== GetUserStatus ==="
curl -sk "https://127.0.0.1:${HTTPS_PORT}/exa.language_server_pb.LanguageServerService/GetUserStatus" \
-H "Content-Type: application/json" \
-H "x-codeium-csrf-token: $MAIN_CSRF" \
-H "Origin: vscode-file://vscode-app" \
-d '{}' 2>/dev/null | python3 -c "
import json, sys
try:
d = json.load(sys.stdin)
us = d.get('userStatus', {})
ps = us.get('planStatus', {})
pi = ps.get('planInfo', {})
print(f'Plan: {pi.get(\"planName\",\"?\")}, Prompt: {ps.get(\"availablePromptCredits\",\"?\")}, Flow: {ps.get(\"availableFlowCredits\",\"?\")}')
ut = us.get('userTier', {})
print(f'Tier: {ut.get(\"name\",\"?\")}')
models = us.get('cascadeModelConfigData', {}).get('clientModelConfigs', [])
print(f'Models: {len(models)}')
for m in models[:5]:
qi = m.get('quotaInfo', {})
print(f' - {m.get(\"label\")}: remaining={qi.get(\"remainingFraction\",\"?\")}')
except Exception as e:
print(f'Error: {e}')
" 2>/dev/null
echo ""
kill "$LS_PID" 2>/dev/null || true
wait "$LS_PID" 2>/dev/null || true
echo "[*] Done"

View File

@@ -9,7 +9,8 @@ use serde_json::Value;
use tracing::info;
/// Strip ALL tool definitions.
const STRIP_ALL_TOOLS: bool = true;
/// Set to false to allow tools through (for tool call testing).
const STRIP_ALL_TOOLS: bool = false;
/// Modify a streamGenerateContent request body in-place.
/// Returns the modified JSON bytes, or None if modification wasn't possible.

View File

@@ -29,6 +29,10 @@ const LS_USER: &str = "antigravity-ls";
/// A running standalone LS process.
pub struct StandaloneLS {
child: Child,
/// The actual LS process PID (may differ from child PID when spawned via sudo).
ls_pid: Option<u32>,
/// Whether the LS was spawned via sudo (needs sudo kill).
use_sudo: bool,
pub port: u16,
pub csrf: String,
}
@@ -196,8 +200,25 @@ impl StandaloneLS {
info!(pid = child.id(), port, "Standalone LS spawned");
// When spawned via sudo, the child is the sudo process which exits after
// launching the LS as the target user. We need the actual LS PID for cleanup.
let ls_pid = if use_sudo {
// Give sudo a moment to spawn the real process
std::thread::sleep(std::time::Duration::from_millis(500));
// Find the LS process owned by antigravity-ls user
find_ls_pid_for_user(LS_USER).ok()
} else {
Some(child.id())
};
if let Some(pid) = ls_pid {
info!(ls_pid = pid, sudo = use_sudo, "Discovered actual LS process");
}
Ok(StandaloneLS {
child,
ls_pid,
use_sudo,
port,
csrf: main_config.csrf.clone(),
})
@@ -239,9 +260,26 @@ impl StandaloneLS {
/// Kill the standalone LS process.
pub fn kill(&mut self) {
info!("Killing standalone LS");
if self.use_sudo {
// The child is sudo which already exited. Kill the actual LS via sudo.
if let Some(pid) = self.ls_pid {
info!(pid, "Killing LS process via sudo");
let _ = std::process::Command::new("sudo")
.args(["-n", "kill", "-TERM", &pid.to_string()])
.status();
// Give it a moment to exit gracefully
std::thread::sleep(std::time::Duration::from_millis(500));
// Force kill if still alive
let _ = std::process::Command::new("sudo")
.args(["-n", "kill", "-KILL", &pid.to_string()])
.status();
}
} else {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
}
impl Drop for StandaloneLS {
@@ -366,6 +404,23 @@ fn has_ls_user() -> bool {
.unwrap_or(false)
}
/// Find the PID of a language_server process owned by a specific user.
///
/// Used to discover the actual LS process after sudo spawns it as a different user.
fn find_ls_pid_for_user(user: &str) -> Result<u32, String> {
let output = Command::new("pgrep")
.args(["-u", user, "-f", "language_server_linux"])
.output()
.map_err(|e| format!("pgrep failed: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout);
stdout
.lines()
.next()
.and_then(|line| line.trim().parse::<u32>().ok())
.ok_or_else(|| format!("No LS process found for user {user}"))
}
#[cfg(test)]
mod tests {
use super::*;