From 3e3af85798d702e0caa6a027c91e288dfea76382 Mon Sep 17 00:00:00 2001 From: Nikketryhard Date: Sat, 14 Feb 2026 22:14:00 -0600 Subject: [PATCH] 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 --- GEMINI.md | 42 +++- scripts/dns-redirect.sh | 163 ------------ scripts/iptables-redirect.sh | 168 ------------- scripts/mitm-wrapper.sh | 329 ------------------------ scripts/parse-snapshot.py | 475 ----------------------------------- scripts/proxyctl | 130 ++++++++++ scripts/standalone-ls.sh | 277 -------------------- src/mitm/modify.rs | 3 +- src/standalone.rs | 59 ++++- 9 files changed, 221 insertions(+), 1425 deletions(-) delete mode 100755 scripts/dns-redirect.sh delete mode 100755 scripts/iptables-redirect.sh delete mode 100755 scripts/mitm-wrapper.sh delete mode 100644 scripts/parse-snapshot.py create mode 100755 scripts/proxyctl delete mode 100755 scripts/standalone-ls.sh diff --git a/GEMINI.md b/GEMINI.md index 8fc23da..9c5de2a 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -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 | diff --git a/scripts/dns-redirect.sh b/scripts/dns-redirect.sh deleted file mode 100755 index 214dce2..0000000 --- a/scripts/dns-redirect.sh +++ /dev/null @@ -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 diff --git a/scripts/iptables-redirect.sh b/scripts/iptables-redirect.sh deleted file mode 100755 index 382461b..0000000 --- a/scripts/iptables-redirect.sh +++ /dev/null @@ -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 diff --git a/scripts/mitm-wrapper.sh b/scripts/mitm-wrapper.sh deleted file mode 100755 index 4dc4276..0000000 --- a/scripts/mitm-wrapper.sh +++ /dev/null @@ -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 \ No newline at end of file diff --git a/scripts/parse-snapshot.py b/scripts/parse-snapshot.py deleted file mode 100644 index 017617e..0000000 --- a/scripts/parse-snapshot.py +++ /dev/null @@ -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() diff --git a/scripts/proxyctl b/scripts/proxyctl new file mode 100755 index 0000000..0aead13 --- /dev/null +++ b/scripts/proxyctl @@ -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 diff --git a/scripts/standalone-ls.sh b/scripts/standalone-ls.sh deleted file mode 100755 index 5b62967..0000000 --- a/scripts/standalone-ls.sh +++ /dev/null @@ -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" diff --git a/src/mitm/modify.rs b/src/mitm/modify.rs index f9dce3a..98d6a1f 100644 --- a/src/mitm/modify.rs +++ b/src/mitm/modify.rs @@ -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. diff --git a/src/standalone.rs b/src/standalone.rs index 3fcfcb5..27fd6da 100644 --- a/src/standalone.rs +++ b/src/standalone.rs @@ -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, + /// 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,8 +260,25 @@ impl StandaloneLS { /// Kill the standalone LS process. pub fn kill(&mut self) { info!("Killing standalone LS"); - let _ = self.child.kill(); - let _ = self.child.wait(); + + 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(); + } } } @@ -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 { + 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::().ok()) + .ok_or_else(|| format!("No LS process found for user {user}")) +} + #[cfg(test)] mod tests { use super::*;